From 497cde5bc4228130e96c242012d983bcac4b86f9 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 9 Jan 2025 18:51:36 +0000 Subject: [PATCH 1/8] Add expiry on line item receive from PO --- .pre-commit-config.yaml | 4 +-- src/backend/InvenTree/order/api.py | 1 + src/backend/InvenTree/order/models.py | 4 +++ src/backend/InvenTree/order/serializers.py | 9 ++++++ src/frontend/src/forms/PurchaseOrderForms.tsx | 30 ++++++++++++++++++- 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5185cbac917c..ec1bef460537 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.9.0 hooks: - id: ruff-format args: [--preview] @@ -28,7 +28,7 @@ repos: --preview ] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.1 + rev: 0.5.16 hooks: - id: pip-compile name: pip-compile requirements-dev.in diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index ab4f677a5605..906307c7d56a 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -410,6 +410,7 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI): - supplier_part: pk value of the supplier part - quantity: quantity to receive - status: stock item status + - expiry_date: stock item expiry date (optional) - location: destination for stock item (optional) - batch_code: the batch code for this stock item - serial_numbers: serial numbers for this stock item diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 2c24f63e8a1c..2b7b1631f580 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -800,6 +800,9 @@ def receive_line_item( # Extract optional batch code for the new stock item batch_code = kwargs.get('batch_code', '') + # Extract optional expiry data for the new stock item + expiry_date = kwargs.get('expiry_date', '') + # Extract optional list of serial numbers serials = kwargs.get('serials') @@ -863,6 +866,7 @@ def receive_line_item( purchase_order=self, status=status, batch=batch_code, + expiry_date=expiry_date, packaging=packaging, serial=sn, purchase_price=unit_purchase_price, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index be0f59bd3820..cc38dc185999 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -717,6 +717,7 @@ class Meta: 'quantity', 'status', 'batch_code', + 'expiry_date', 'serial_numbers', 'packaging', 'note', @@ -765,6 +766,13 @@ def validate_quantity(self, quantity): allow_blank=True, ) + expiry_date = serializers.DateField( + label=_('Expiry Date'), + help_text=_('Enter expiry date for incoming stock items'), + required=False, + default=None, + ) + serial_numbers = serializers.CharField( label=_('Serial Numbers'), help_text=_('Enter serial numbers for incoming stock items'), @@ -967,6 +975,7 @@ def save(self): status=item['status'], barcode=item.get('barcode', ''), batch_code=item.get('batch_code', ''), + expiry_date=item.get('expiry_date', ''), packaging=item.get('packaging', ''), serials=item.get('serials', None), notes=item.get('note', None), diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 8523a3a28fe8..6ee7af7670f9 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -25,6 +25,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; +import { IconCalendarExclamation } from '@tabler/icons-react'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; @@ -49,7 +50,6 @@ import { useSerialNumberGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; - /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ @@ -298,6 +298,12 @@ function LineItemFormRow({ } }); + const [expiryDateOpen, expiryDateHandlers] = useDisclosure(false, { + onClose: () => { + props.changeFn(props.idx, 'expiry_date', undefined); + } + }); + // Status value const [statusOpen, statusHandlers] = useDisclosure(false, { onClose: () => props.changeFn(props.idx, 'status', undefined) @@ -440,6 +446,14 @@ function LineItemFormRow({ tooltipAlignment='top' variant={batchOpen ? 'filled' : 'transparent'} /> + expiryDateHandlers.toggle()} + icon={} + tooltip={t`Set Expiry Date`} + tooltipAlignment='top' + variant={expiryDateOpen ? 'filled' : 'transparent'} + /> } @@ -586,6 +600,19 @@ function LineItemFormRow({ }} error={props.rowErrors?.serial_numbers?.message} /> + + props.changeFn(props.idx, 'expiry_date', value) + } + fieldDefinition={{ + field_type: 'date', + label: t`Expiry Date`, + description: t`Enter an expiry date for received items`, + value: props.item.expiry_date + }} + error={props.rowErrors?.expiry_date?.message} + /> props.changeFn(props.idx, 'packaging', value)} @@ -672,6 +699,7 @@ export function useReceiveLineItems(props: LineItemsForm) { line_item: elem.pk, location: elem.destination ?? elem.destination_detail?.pk ?? null, quantity: elem.quantity - elem.received, + expiry_date: null, batch_code: '', serial_numbers: '', status: 10, From b6b0af55357fe209cb9545499d03471743753c5b Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Thu, 9 Jan 2025 21:46:23 +0000 Subject: [PATCH 2/8] add backend test --- src/backend/InvenTree/order/test_api.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 97cdc0d3ee47..e0148b72f1ea 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -3,7 +3,7 @@ import base64 import io import json -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from django.core.exceptions import ValidationError from django.db import connection @@ -1061,12 +1061,20 @@ def test_valid(self): self.assertEqual(line_1.received, 0) self.assertEqual(line_2.received, 50) + one_week_from_today = date.today() + timedelta(days=7) + valid_data = { 'items': [ - {'line_item': 1, 'quantity': 50, 'barcode': 'MY-UNIQUE-BARCODE-123'}, + { + 'line_item': 1, + 'quantity': 50, + 'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'), + 'barcode': 'MY-UNIQUE-BARCODE-123', + }, { 'line_item': 2, 'quantity': 200, + 'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'), 'location': 2, # Explicit location 'barcode': 'MY-UNIQUE-BARCODE-456', }, @@ -1111,6 +1119,10 @@ def test_valid(self): self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_2.last().location.pk, 2) + # Expiry dates should be set + self.assertEqual(stock_1.last().expiry_date, one_week_from_today) + self.assertEqual(stock_2.last().expiry_date, one_week_from_today) + # Barcodes should have been assigned to the stock items self.assertTrue( StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() From 104951ad6cd73b6086c5689a0e423d12b43221ce Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 10 Jan 2025 02:24:32 +0100 Subject: [PATCH 3/8] reset pre-commit --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec1bef460537..5185cbac917c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.0 + rev: v0.7.3 hooks: - id: ruff-format args: [--preview] @@ -28,7 +28,7 @@ repos: --preview ] - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.16 + rev: 0.5.1 hooks: - id: pip-compile name: pip-compile requirements-dev.in From 0cd2a9f3fc65f6c0d7f233eb002bed613f280fb6 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 10 Jan 2025 17:56:27 +0000 Subject: [PATCH 4/8] increment inventree api version --- src/backend/InvenTree/InvenTree/api_version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 9e3d1d5bb677..f8851a71b447 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 298 +INVENTREE_API_VERSION = 299 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v299 - 2025-01-10 - https://github.com/inventree/InvenTree/pull/8867 + - Adds 'expiry_date' field to the PurchaseOrderReceive API endpoint + v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848 - Adds 'created_by' field to PurchaseOrder API endpoints - Adds 'created_by' field to SalesOrder API endpoints From 1084f392246c573f3b54792f5b94b8f0af74be97 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 10 Jan 2025 18:03:20 +0000 Subject: [PATCH 5/8] use None as default expiry date --- src/backend/InvenTree/order/models.py | 4 ++-- src/backend/InvenTree/order/serializers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 2b7b1631f580..a61b1009687f 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -800,8 +800,8 @@ def receive_line_item( # Extract optional batch code for the new stock item batch_code = kwargs.get('batch_code', '') - # Extract optional expiry data for the new stock item - expiry_date = kwargs.get('expiry_date', '') + # Extract optional expiry date for the new stock item + expiry_date = kwargs.get('expiry_date') # Extract optional list of serial numbers serials = kwargs.get('serials') diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index cc38dc185999..aaba8eba9a11 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -975,7 +975,7 @@ def save(self): status=item['status'], barcode=item.get('barcode', ''), batch_code=item.get('batch_code', ''), - expiry_date=item.get('expiry_date', ''), + expiry_date=item.get('expiry_date', None), packaging=item.get('packaging', ''), serials=item.get('serials', None), notes=item.get('note', None), From bd3e3b0d81afe1acf91ad9a619af22472d7f5b24 Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 10 Jan 2025 18:29:55 +0000 Subject: [PATCH 6/8] check global setting STOCK_ENABLE_EXPIRY --- src/frontend/src/forms/PurchaseOrderForms.tsx | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index 6ee7af7670f9..d7cf8643a58f 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -50,6 +50,7 @@ import { useSerialNumberGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; +import { useGlobalSettingsState } from '../states/SettingsState'; /* * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance */ @@ -246,6 +247,8 @@ function LineItemFormRow({ [record] ); + const settings = useGlobalSettingsState(); + useEffect(() => { if (!!record.destination) { props.changeFn(props.idx, 'location', record.destination); @@ -446,14 +449,16 @@ function LineItemFormRow({ tooltipAlignment='top' variant={batchOpen ? 'filled' : 'transparent'} /> - expiryDateHandlers.toggle()} - icon={} - tooltip={t`Set Expiry Date`} - tooltipAlignment='top' - variant={expiryDateOpen ? 'filled' : 'transparent'} - /> + {settings.isSet('STOCK_ENABLE_EXPIRY') && ( + expiryDateHandlers.toggle()} + icon={} + tooltip={t`Set Expiry Date`} + tooltipAlignment='top' + variant={expiryDateOpen ? 'filled' : 'transparent'} + /> + )} } @@ -600,19 +605,21 @@ function LineItemFormRow({ }} error={props.rowErrors?.serial_numbers?.message} /> - - props.changeFn(props.idx, 'expiry_date', value) - } - fieldDefinition={{ - field_type: 'date', - label: t`Expiry Date`, - description: t`Enter an expiry date for received items`, - value: props.item.expiry_date - }} - error={props.rowErrors?.expiry_date?.message} - /> + {settings.isSet('STOCK_ENABLE_EXPIRY') && ( + + props.changeFn(props.idx, 'expiry_date', value) + } + fieldDefinition={{ + field_type: 'date', + label: t`Expiry Date`, + description: t`Enter an expiry date for received items`, + value: props.item.expiry_date + }} + error={props.rowErrors?.expiry_date?.message} + /> + )} props.changeFn(props.idx, 'packaging', value)} From 579a61b42c38f68177307611fedc8097e536444f Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Fri, 10 Jan 2025 19:15:07 +0000 Subject: [PATCH 7/8] check for default expiry in line item receive --- src/backend/InvenTree/InvenTree/api_version.py | 1 + src/backend/InvenTree/part/serializers.py | 1 + src/frontend/src/forms/PurchaseOrderForms.tsx | 13 +++++++++++++ 3 files changed, 15 insertions(+) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f8851a71b447..25a870de4525 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -10,6 +10,7 @@ v299 - 2025-01-10 - https://github.com/inventree/InvenTree/pull/8867 - Adds 'expiry_date' field to the PurchaseOrderReceive API endpoint + - Adds 'default_expiry` field to the PartBriefSerializer, affecting API endpoints which use it v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848 - Adds 'created_by' field to PurchaseOrder API endpoints diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 68ab21990a36..2fcdf1206a2b 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -352,6 +352,7 @@ class Meta: 'barcode_hash', 'category_default_location', 'default_location', + 'default_expiry', 'name', 'revision', 'full_name', diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index d7cf8643a58f..e3cc1b373803 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -302,6 +302,19 @@ function LineItemFormRow({ }); const [expiryDateOpen, expiryDateHandlers] = useDisclosure(false, { + onOpen: () => { + // check the default part expiry. Assume expiry is relative to today + const defaultExpiry = record.part_detail?.default_expiry; + if (defaultExpiry !== undefined && defaultExpiry > 0) { + const defaultExpiryDate = new Date(); + defaultExpiryDate.setDate(defaultExpiryDate.getDate() + defaultExpiry); + const year = defaultExpiryDate.getFullYear(); + const month = String(defaultExpiryDate.getMonth() + 1).padStart(2, '0'); // January is 0! + const day = String(defaultExpiryDate.getDate()).padStart(2, '0'); + const defaultExpiryDateString = `${year}-${month}-${day}`; + props.changeFn(props.idx, 'expiry_date', defaultExpiryDateString); + } + }, onClose: () => { props.changeFn(props.idx, 'expiry_date', undefined); } From 1bbc97cf596aa5110730c83dc4463476159b632d Mon Sep 17 00:00:00 2001 From: Jacob Felknor Date: Sat, 11 Jan 2025 18:43:29 +0000 Subject: [PATCH 8/8] use dayjs --- src/frontend/src/forms/PurchaseOrderForms.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index e3cc1b373803..50e0b2c66e36 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -26,6 +26,7 @@ import { useQuery } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { IconCalendarExclamation } from '@tabler/icons-react'; +import dayjs from 'dayjs'; import { api } from '../App'; import { ActionButton } from '../components/buttons/ActionButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton'; @@ -306,13 +307,11 @@ function LineItemFormRow({ // check the default part expiry. Assume expiry is relative to today const defaultExpiry = record.part_detail?.default_expiry; if (defaultExpiry !== undefined && defaultExpiry > 0) { - const defaultExpiryDate = new Date(); - defaultExpiryDate.setDate(defaultExpiryDate.getDate() + defaultExpiry); - const year = defaultExpiryDate.getFullYear(); - const month = String(defaultExpiryDate.getMonth() + 1).padStart(2, '0'); // January is 0! - const day = String(defaultExpiryDate.getDate()).padStart(2, '0'); - const defaultExpiryDateString = `${year}-${month}-${day}`; - props.changeFn(props.idx, 'expiry_date', defaultExpiryDateString); + props.changeFn( + props.idx, + 'expiry_date', + dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD') + ); } }, onClose: () => {