2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-20 03:36:30 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue6281

This commit is contained in:
Matthias Mair
2025-01-12 04:58:35 +01:00
87 changed files with 40208 additions and 40479 deletions
src
backend
InvenTree
InvenTree
locale
ar
LC_MESSAGES
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
et
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lt
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_BR
LC_MESSAGES
ro
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
uk
LC_MESSAGES
vi
LC_MESSAGES
zh_Hans
LC_MESSAGES
zh_Hant
LC_MESSAGES
order
part
frontend

@@ -11,6 +11,10 @@ v299 - 2025-01-10 : https://github.com/inventree/InvenTree/pull/6293
- Removes a considerable amount of old auth endpoints - Removes a considerable amount of old auth endpoints
- Introduces allauth based REST API - Introduces allauth based REST API
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 v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848
- Adds 'created_by' field to PurchaseOrder API endpoints - Adds 'created_by' field to PurchaseOrder API endpoints
- Adds 'created_by' field to SalesOrder API endpoints - Adds 'created_by' field to SalesOrder API endpoints

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@@ -410,6 +410,7 @@ class PurchaseOrderReceive(PurchaseOrderContextMixin, CreateAPI):
- supplier_part: pk value of the supplier part - supplier_part: pk value of the supplier part
- quantity: quantity to receive - quantity: quantity to receive
- status: stock item status - status: stock item status
- expiry_date: stock item expiry date (optional)
- location: destination for stock item (optional) - location: destination for stock item (optional)
- batch_code: the batch code for this stock item - batch_code: the batch code for this stock item
- serial_numbers: serial numbers for this stock item - serial_numbers: serial numbers for this stock item

@@ -796,10 +796,32 @@ class PurchaseOrder(TotalPriceMixin, Order):
def receive_line_item( def receive_line_item(
self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs self, line, location, quantity, user, status=StockStatus.OK.value, **kwargs
): ):
"""Receive a line item (or partial line item) against this PurchaseOrder.""" """Receive a line item (or partial line item) against this PurchaseOrder.
Arguments:
line: The PurchaseOrderLineItem to receive against
location: The StockLocation to receive the item into
quantity: The quantity to receive
user: The User performing the action
status: The StockStatus to assign to the item (default: StockStatus.OK)
Keyword Arguments:
barch_code: Optional batch code for the new StockItem
serials: Optional list of serial numbers to assign to the new StockItem(s)
notes: Optional notes field for the StockItem
packaging: Optional packaging field for the StockItem
barcode: Optional barcode field for the StockItem
Raises:
ValidationError: If the quantity is negative or otherwise invalid
ValidationError: If the order is not in the 'PLACED' state
"""
# Extract optional batch code for the new stock item # Extract optional batch code for the new stock item
batch_code = kwargs.get('batch_code', '') batch_code = kwargs.get('batch_code', '')
# Extract optional expiry date for the new stock item
expiry_date = kwargs.get('expiry_date')
# Extract optional list of serial numbers # Extract optional list of serial numbers
serials = kwargs.get('serials') serials = kwargs.get('serials')
@@ -863,6 +885,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
purchase_order=self, purchase_order=self,
status=status, status=status,
batch=batch_code, batch=batch_code,
expiry_date=expiry_date,
packaging=packaging, packaging=packaging,
serial=sn, serial=sn,
purchase_price=unit_purchase_price, purchase_price=unit_purchase_price,

@@ -717,6 +717,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
'quantity', 'quantity',
'status', 'status',
'batch_code', 'batch_code',
'expiry_date',
'serial_numbers', 'serial_numbers',
'packaging', 'packaging',
'note', 'note',
@@ -765,6 +766,13 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
allow_blank=True, 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( serial_numbers = serializers.CharField(
label=_('Serial Numbers'), label=_('Serial Numbers'),
help_text=_('Enter serial numbers for incoming stock items'), help_text=_('Enter serial numbers for incoming stock items'),
@@ -967,6 +975,7 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
status=item['status'], status=item['status'],
barcode=item.get('barcode', ''), barcode=item.get('barcode', ''),
batch_code=item.get('batch_code', ''), batch_code=item.get('batch_code', ''),
expiry_date=item.get('expiry_date', None),
packaging=item.get('packaging', ''), packaging=item.get('packaging', ''),
serials=item.get('serials', None), serials=item.get('serials', None),
notes=item.get('note', None), notes=item.get('note', None),

@@ -3,7 +3,7 @@
import base64 import base64
import io import io
import json import json
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
@@ -1061,12 +1061,20 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(line_1.received, 0) self.assertEqual(line_1.received, 0)
self.assertEqual(line_2.received, 50) self.assertEqual(line_2.received, 50)
one_week_from_today = date.today() + timedelta(days=7)
valid_data = { valid_data = {
'items': [ '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, 'line_item': 2,
'quantity': 200, 'quantity': 200,
'expiry_date': one_week_from_today.strftime(r'%Y-%m-%d'),
'location': 2, # Explicit location 'location': 2, # Explicit location
'barcode': 'MY-UNIQUE-BARCODE-456', 'barcode': 'MY-UNIQUE-BARCODE-456',
}, },
@@ -1111,6 +1119,10 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_1.last().location.pk, 1) self.assertEqual(stock_1.last().location.pk, 1)
self.assertEqual(stock_2.last().location.pk, 2) 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 # Barcodes should have been assigned to the stock items
self.assertTrue( self.assertTrue(
StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists() StockItem.objects.filter(barcode_data='MY-UNIQUE-BARCODE-123').exists()

@@ -352,6 +352,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'barcode_hash', 'barcode_hash',
'category_default_location', 'category_default_location',
'default_location', 'default_location',
'default_expiry',
'name', 'name',
'revision', 'revision',
'full_name', 'full_name',

@@ -47,6 +47,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
ru: t`Russian`, ru: t`Russian`,
sk: t`Slovak`, sk: t`Slovak`,
sl: t`Slovenian`, sl: t`Slovenian`,
sr: t`Serbian`,
sv: t`Swedish`, sv: t`Swedish`,
th: t`Thai`, th: t`Thai`,
tr: t`Turkish`, tr: t`Turkish`,

@@ -25,6 +25,8 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { IconCalendarExclamation } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { api } from '../App'; import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton'; import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
@@ -49,7 +51,7 @@ import {
useSerialNumberGenerator useSerialNumberGenerator
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { apiUrl } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { useGlobalSettingsState } from '../states/SettingsState';
/* /*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance * Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/ */
@@ -246,6 +248,8 @@ function LineItemFormRow({
[record] [record]
); );
const settings = useGlobalSettingsState();
useEffect(() => { useEffect(() => {
if (!!record.destination) { if (!!record.destination) {
props.changeFn(props.idx, 'location', record.destination); props.changeFn(props.idx, 'location', record.destination);
@@ -298,6 +302,23 @@ 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) {
props.changeFn(
props.idx,
'expiry_date',
dayjs().add(defaultExpiry, 'day').format('YYYY-MM-DD')
);
}
},
onClose: () => {
props.changeFn(props.idx, 'expiry_date', undefined);
}
});
// Status value // Status value
const [statusOpen, statusHandlers] = useDisclosure(false, { const [statusOpen, statusHandlers] = useDisclosure(false, {
onClose: () => props.changeFn(props.idx, 'status', undefined) onClose: () => props.changeFn(props.idx, 'status', undefined)
@@ -440,6 +461,16 @@ function LineItemFormRow({
tooltipAlignment='top' tooltipAlignment='top'
variant={batchOpen ? 'filled' : 'transparent'} variant={batchOpen ? 'filled' : 'transparent'}
/> />
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
<ActionButton
size='sm'
onClick={() => expiryDateHandlers.toggle()}
icon={<IconCalendarExclamation />}
tooltip={t`Set Expiry Date`}
tooltipAlignment='top'
variant={expiryDateOpen ? 'filled' : 'transparent'}
/>
)}
<ActionButton <ActionButton
size='sm' size='sm'
icon={<InvenTreeIcon icon='packaging' />} icon={<InvenTreeIcon icon='packaging' />}
@@ -586,6 +617,21 @@ function LineItemFormRow({
}} }}
error={props.rowErrors?.serial_numbers?.message} error={props.rowErrors?.serial_numbers?.message}
/> />
{settings.isSet('STOCK_ENABLE_EXPIRY') && (
<TableFieldExtraRow
visible={expiryDateOpen}
onValueChange={(value) =>
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}
/>
)}
<TableFieldExtraRow <TableFieldExtraRow
visible={packagingOpen} visible={packagingOpen}
onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)} onValueChange={(value) => props.changeFn(props.idx, 'packaging', value)}
@@ -672,6 +718,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
line_item: elem.pk, line_item: elem.pk,
location: elem.destination ?? elem.destination_detail?.pk ?? null, location: elem.destination ?? elem.destination_detail?.pk ?? null,
quantity: elem.quantity - elem.received, quantity: elem.quantity - elem.received,
expiry_date: null,
batch_code: '', batch_code: '',
serial_numbers: '', serial_numbers: '',
status: 10, status: 10,

@@ -13,6 +13,7 @@ import {
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useEffect, useMemo, useState } from 'react'; import { Suspense, useEffect, useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { api } from '../App'; import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton'; import { ActionButton } from '../components/buttons/ActionButton';
import RemoveRowButton from '../components/buttons/RemoveRowButton'; import RemoveRowButton from '../components/buttons/RemoveRowButton';
@@ -68,6 +69,8 @@ export function useStockFields({
const [nextBatchCode, setNextBatchCode] = useState<string>(''); const [nextBatchCode, setNextBatchCode] = useState<string>('');
const [nextSerialNumber, setNextSerialNumber] = useState<string>(''); const [nextSerialNumber, setNextSerialNumber] = useState<string>('');
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const batchGenerator = useBatchCodeGenerator((value: any) => { const batchGenerator = useBatchCodeGenerator((value: any) => {
if (value) { if (value) {
setNextBatchCode(`${t`Next batch code`}: ${value}`); setNextBatchCode(`${t`Next batch code`}: ${value}`);
@@ -106,6 +109,14 @@ export function useStockFields({
// Clear the 'supplier_part' field if the part is changed // Clear the 'supplier_part' field if the part is changed
setSupplierPart(null); setSupplierPart(null);
// Adjust the 'expiry date' for the stock item
const expiry_days = record?.default_expiry ?? 0;
if (expiry_days && expiry_days > 0) {
// Adjust the expiry date based on the part default expiry
setExpiryDate(dayjs().add(expiry_days, 'days').toISOString());
}
} }
}, },
supplier_part: { supplier_part: {
@@ -171,7 +182,11 @@ export function useStockFields({
}, },
expiry_date: { expiry_date: {
icon: <IconCalendarExclamation />, icon: <IconCalendarExclamation />,
hidden: !globalSettings.isSet('STOCK_ENABLE_EXPIRY') hidden: !globalSettings.isSet('STOCK_ENABLE_EXPIRY'),
value: expiryDate,
onValueChange: (value) => {
setExpiryDate(value);
}
}, },
purchase_price: { purchase_price: {
icon: <IconCurrencyDollar /> icon: <IconCurrencyDollar />
@@ -194,9 +209,15 @@ export function useStockFields({
// TODO: Handle custom field management based on provided options // TODO: Handle custom field management based on provided options
// TODO: refer to stock.py in original codebase // TODO: refer to stock.py in original codebase
// Remove the expiry date field if it is not enabled
if (!globalSettings.isSet('STOCK_ENABLE_EXPIRY')) {
delete fields.expiry_date;
}
return fields; return fields;
}, [ }, [
stockItem, stockItem,
expiryDate,
partInstance, partInstance,
partId, partId,
globalSettings, globalSettings,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff