Merged PR 1975: hotfix(remission-list): prioritize reload trigger over exact search

hotfix(remission-list): prioritize reload trigger over exact search

Fix navigation issue where reload searches were incorrectly applying
exact search logic, causing filters to be cleared when they should
be preserved during navigation.

Changes:
- Update remission-list.resource.ts to check reload trigger before
  exact search conditions
- Ensure reload trigger always clears input but preserves other query
  parameters
- Prevent exact search from overriding reload behavior
- Add explanatory comment for reload priority logic

This ensures proper filter state management when users navigate
between remission lists, maintaining expected behavior for both
reload and exact search scenarios.

Ref: #5387
This commit is contained in:
Nino Righi
2025-10-21 12:08:06 +00:00
committed by Lorenz Hilpert
parent 4c56f394c5
commit 1b6b726036
13 changed files with 396 additions and 22 deletions

View File

@@ -3,3 +3,4 @@ export * from './lib/models';
export * from './lib/stores';
export * from './lib/schemas';
export * from './lib/helpers';
export * from './lib/guards';

View File

@@ -0,0 +1,2 @@
export { isReturnItem } from './is-return-item';
export { isReturnSuggestion } from './is-return-suggestion';

View File

@@ -0,0 +1,42 @@
import { ReturnItem } from '../models/return-item';
/**
* Type guard to check if an object is a valid ReturnItem
* @param value - The value to check
* @returns True if the value is a ReturnItem, false otherwise
*/
export const isReturnItem = (value: unknown): value is ReturnItem => {
if (!value || typeof value !== 'object') {
return false;
}
const item = value as Partial<ReturnItem>;
// Check required properties from ReturnItem
return (
// Check product exists and has required properties
typeof item.product === 'object' &&
item.product !== null &&
(typeof item.product.name === 'string' ||
typeof item.product.ean === 'string') &&
// Check retailPrice exists and has required nested structure
typeof item.retailPrice === 'object' &&
item.retailPrice !== null &&
typeof item.retailPrice.value === 'object' &&
item.retailPrice.value !== null &&
typeof item.retailPrice.value.value === 'number' &&
typeof item.retailPrice.value.currency === 'string' &&
// Check source exists and is a valid string
typeof item.source === 'string' &&
item.source.length > 0 &&
// Check inherited ReturnItemDTO properties (id is a number)
typeof item.id === 'number' &&
// ReturnItem-specific: Must NOT have ReturnSuggestion-specific fields
!('accepted' in item) &&
!('rejected' in item) &&
!('sort' in item) &&
// ReturnItem can have predefinedReturnQuantity, quantityReturned, or neither
// If it has none of the ReturnSuggestion-specific fields, it's a ReturnItem
true
);
};

View File

@@ -0,0 +1,44 @@
import { ReturnSuggestion } from '../models/return-suggestion';
/**
* Type guard to check if an object is a valid ReturnSuggestion
* @param value - The value to check
* @returns True if the value is a ReturnSuggestion, false otherwise
*/
export const isReturnSuggestion = (
value: unknown,
): value is ReturnSuggestion => {
if (!value || typeof value !== 'object') {
return false;
}
const suggestion = value as Partial<ReturnSuggestion>;
// Check required properties from ReturnSuggestion
return (
// Check product exists and has required properties
typeof suggestion.product === 'object' &&
suggestion.product !== null &&
(typeof suggestion.product.name === 'string' ||
typeof suggestion.product.ean === 'string') &&
// Check retailPrice exists and has required nested structure
typeof suggestion.retailPrice === 'object' &&
suggestion.retailPrice !== null &&
typeof suggestion.retailPrice.value === 'object' &&
suggestion.retailPrice.value !== null &&
typeof suggestion.retailPrice.value.value === 'number' &&
typeof suggestion.retailPrice.value.currency === 'string' &&
// Check source exists and is a valid string
typeof suggestion.source === 'string' &&
suggestion.source.length > 0 &&
// Check inherited ReturnSuggestionDTO properties (id is a number)
typeof suggestion.id === 'number' &&
// ReturnSuggestion-specific: Must have at least one distinguishing property
('accepted' in suggestion ||
'rejected' in suggestion ||
'sort' in suggestion) &&
// Additionally, must NOT have ReturnItem-specific fields
!('predefinedReturnQuantity' in suggestion) &&
!('quantityReturned' in suggestion)
);
};

View File

@@ -0,0 +1,191 @@
import { getItemType } from './get-item-type.helper';
import { RemissionItemType } from '../models';
import { RemissionItem } from '../stores';
describe('getItemType', () => {
describe('Happy Path - ReturnItem', () => {
it('should return ReturnItem when item has predefinedReturnQuantity', () => {
// Arrange
const item = {
id: 123,
product: {
name: 'Test Product',
ean: '1234567890123',
},
retailPrice: {
value: {
value: 10.99,
currency: 'EUR',
},
},
source: 'manually-added',
predefinedReturnQuantity: 1,
quantity: 1,
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
it('should return ReturnItem when item has no suggestion-specific fields', () => {
// Arrange
const item = {
id: 456,
product: {
name: 'Another Product',
ean: '9876543210987',
},
retailPrice: {
value: {
value: 15.5,
currency: 'EUR',
},
},
source: 'DisposalListModule',
returnReason: 'Herstellerfehler',
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
});
describe('Happy Path - ReturnSuggestion', () => {
it('should return ReturnSuggestion when item has sort field', () => {
// Arrange
const item = {
id: 789,
product: {
name: 'Suggestion Product',
ean: '5555555555555',
contributors: 'Test Author',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 20.0,
currency: 'EUR',
},
},
source: 'manually-added',
quantity: 3,
sort: 1,
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
it('should return ReturnSuggestion when item has accepted field', () => {
// Arrange
const item = {
id: 101,
product: {
name: 'Accepted Product',
ean: '1111111111111',
contributors: 'Test Author',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 8.99,
currency: 'EUR',
},
},
source: 'DisposalListModule',
quantity: 2,
accepted: '2025-10-20T10:00:00Z',
} as RemissionItem;
// Act
const result = getItemType(item);
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
});
describe('Fallback with RemissionListType', () => {
it('should return ReturnItem when neither guard matches and remissionListType is Pflichtremission', () => {
// Arrange - Item that doesn't match either guard (missing required fields)
const ambiguousItem = {
id: 202,
product: {
name: 'Ambiguous Product',
ean: '1234567890',
contributors: 'Test',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 12.5,
currency: 'EUR',
},
},
// Missing 'source' field - will fail both guards
quantity: 1,
} as unknown as RemissionItem;
// Act
const result = getItemType(ambiguousItem, 'Pflichtremission');
// Assert
expect(result).toBe(RemissionItemType.ReturnItem);
});
it('should return ReturnSuggestion when neither guard matches and remissionListType is Abteilungsremission', () => {
// Arrange - Item that doesn't match either guard (missing required fields)
const ambiguousItem = {
id: 303,
product: {
name: 'Another Ambiguous Product',
ean: '9876543210',
contributors: 'Test',
format: 'TB',
formatDetail: 'Taschenbuch',
},
retailPrice: {
value: {
value: 7.5,
currency: 'EUR',
},
},
// Missing 'source' field - will fail both guards
quantity: 1,
} as unknown as RemissionItem;
// Act
const result = getItemType(ambiguousItem, 'Abteilungsremission');
// Assert
expect(result).toBe(RemissionItemType.ReturnSuggestion);
});
});
describe('Unknown Type', () => {
it('should return Unknown when no guards match and no remissionListType provided', () => {
// Arrange
const invalidItem = {
id: 404,
} as RemissionItem;
// Act
const result = getItemType(invalidItem);
// Assert
expect(result).toBe(RemissionItemType.Unknown);
});
});
});

View File

@@ -0,0 +1,42 @@
import { RemissionItemType, RemissionListType } from '../models';
import { RemissionItem } from '../stores';
import { isReturnItem, isReturnSuggestion } from '../guards';
/**
* Determines the concrete type of a RemissionItem
* @param item - The item to check
* @param remissionListType - Optional fallback to determine type when guards are ambiguous
* @returns The type as a RemissionItemType enum value
*/
export const getItemType = (
item: RemissionItem,
remissionListType?: RemissionListType,
): RemissionItemType => {
const isReturnItemType = isReturnItem(item);
const isReturnSuggestionItemType = isReturnSuggestion(item);
// If exactly one guard matches, use it directly (without remissionListType)
if (isReturnItemType && !isReturnSuggestionItemType) {
return RemissionItemType.ReturnItem;
}
if (isReturnSuggestionItemType && !isReturnItemType) {
return RemissionItemType.ReturnSuggestion;
}
// If both are true or both are false, use remissionListType as fallback
if (remissionListType) {
// Pflichtremission typically contains ReturnItems
// Abteilungsremission typically contains ReturnSuggestions
if (remissionListType === 'Pflichtremission') {
return RemissionItemType.ReturnItem;
}
if (remissionListType === 'Abteilungsremission') {
return RemissionItemType.ReturnSuggestion;
}
}
// If no remissionListType provided or unrecognized, return Unknown
return RemissionItemType.Unknown;
};

View File

@@ -10,3 +10,4 @@ export * from './get-package-numbers-from-return.helper';
export * from './get-retail-price-from-item.helper';
export * from './get-assortment-from-item.helper';
export * from './order-by-list-items.helper';
export * from './get-item-type.helper';

View File

@@ -21,3 +21,4 @@ export * from './receipt-complete-status';
export * from './remission-response-args-error-message';
export * from './impediment';
export * from './update-item';
export * from './remission-item-type';

View File

@@ -0,0 +1,9 @@
export const RemissionItemType = {
ReturnItem: 'ReturnItem',
ReturnSuggestion: 'ReturnSuggestion',
Unknown: 'Unknown',
} as const;
export type RemissionItemType = keyof typeof RemissionItemType;
export type RemissionItemTypeValue =
(typeof RemissionItemType)[RemissionItemType];

View File

@@ -8,11 +8,11 @@ import {
Stock,
Receipt,
ReturnItem,
RemissionListType,
ReceiptReturnTuple,
ReceiptReturnSuggestionTuple,
ReturnSuggestion,
CreateRemission,
RemissionItemType,
} from '../models';
import { subDays } from 'date-fns';
import { of, throwError } from 'rxjs';
@@ -1559,12 +1559,12 @@ describe('RemissionReturnReceiptService', () => {
jest.restoreAllMocks();
});
it('should call addReturnSuggestionItem for Abteilung type', async () => {
it('should call addReturnSuggestionItem for ReturnSuggestion type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act
@@ -1578,16 +1578,18 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
expect(service.addReturnItem).not.toHaveBeenCalled();
});
it('should call addReturnItem for Pflicht type', async () => {
it('should call addReturnItem for ReturnItem type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act
@@ -1605,12 +1607,12 @@ describe('RemissionReturnReceiptService', () => {
expect(service.addReturnSuggestionItem).not.toHaveBeenCalled();
});
it('should return undefined for unknown type', async () => {
it('should return undefined for Unknown type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: 'Unknown' as RemissionListType,
type: RemissionItemType.Unknown,
};
// Act
@@ -1630,7 +1632,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act & Assert
@@ -1641,6 +1643,8 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
});
@@ -1652,7 +1656,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act & Assert
@@ -1675,7 +1679,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
type: RemissionItemType.ReturnSuggestion,
};
// Act
@@ -1689,6 +1693,8 @@ describe('RemissionReturnReceiptService', () => {
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
impedimentComment: undefined,
remainingQuantity: undefined,
});
});
@@ -1699,7 +1705,7 @@ describe('RemissionReturnReceiptService', () => {
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
type: RemissionItemType.ReturnItem,
};
// Act

View File

@@ -29,7 +29,7 @@ import {
Receipt,
ReceiptReturnSuggestionTuple,
ReceiptReturnTuple,
RemissionListType,
RemissionItemType,
ReturnItem,
ReturnSuggestion,
} from '../models';
@@ -905,14 +905,42 @@ export class RemissionReturnReceiptService {
/**
* Remits an item to the return receipt based on its type.
* Determines whether to add a return item or return suggestion item based on the remission list type.
* Determines whether to add a return item or return suggestion item based on the remission item type.
*
* @async
* @param {Object} params - The parameters for remitting the item
* @param {number} params.itemId - The ID of the item to remit
* @param {AddReturnItem | AddReturnSuggestionItem} params.addItem - The item data to add
* @param {RemissionListType} params.type - The type of remission list (Abteilung or Pflicht)
* @param {Omit<AddReturnItem, 'returnItemId'> | Omit<AddReturnSuggestionItem, 'returnSuggestionId'>} params.addItem - The item data to add (without the item ID field)
* @param {RemissionItemType} params.type - The type of remission item (ReturnItem, ReturnSuggestion, or Unknown)
* @returns {Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined>} The updated receipt and return tuple if successful, undefined otherwise
*
* @example
* // Remit a ReturnItem
* const result = await service.remitItem({
* itemId: 123,
* addItem: {
* returnId: 1,
* receiptId: 2,
* quantity: 10,
* inStock: 5,
* },
* type: RemissionItemType.ReturnItem,
* });
*
* @example
* // Remit a ReturnSuggestion
* const result = await service.remitItem({
* itemId: 456,
* addItem: {
* returnId: 1,
* receiptId: 2,
* quantity: 10,
* inStock: 5,
* impedimentComment: 'Restmenge',
* remainingQuantity: 5,
* },
* type: RemissionItemType.ReturnSuggestion,
* });
*/
async remitItem({
itemId,
@@ -923,10 +951,17 @@ export class RemissionReturnReceiptService {
addItem:
| Omit<AddReturnItem, 'returnItemId'>
| Omit<AddReturnSuggestionItem, 'returnSuggestionId'>;
type: RemissionListType;
type: RemissionItemType;
}): Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined> {
if (type === RemissionItemType.Unknown) {
this.#logger.error(
'Invalid remission item type: None. Cannot remit item.',
);
return;
}
// ReturnSuggestion
if (type === RemissionListType.Abteilung) {
if (type === RemissionItemType.ReturnSuggestion) {
return await this.addReturnSuggestionItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
@@ -941,7 +976,7 @@ export class RemissionReturnReceiptService {
}
// ReturnItem
if (type === RemissionListType.Pflicht) {
if (type === RemissionItemType.ReturnItem) {
return await this.addReturnItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,

View File

@@ -48,6 +48,7 @@ import {
RemissionResponseArgsErrorMessage,
UpdateItem,
orderByListItems,
getItemType,
} from '@isa/remission/data-access';
import { injectDialog, injectFeedbackErrorDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
@@ -528,7 +529,7 @@ export class RemissionListComponent {
? undefined
: inStock - quantity,
},
type: remissionListType,
type: getItemType(item, remissionListType),
});
}
}

View File

@@ -77,11 +77,10 @@ export const createRemissionListResource = (
const isReload = params.searchTrigger === 'reload';
// #5273
// #5387 Hotfix Navigation | Reload has priority over Exact Search
if (isReload) {
queryToken.input = {};
}
if (exactSearch) {
} else if (exactSearch) {
queryToken.filter = {};
queryToken.orderBy = [];
}