Merge branch 'master' into develop

This commit is contained in:
Lorenz Hilpert
2025-10-21 14:09:53 +02:00
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 = [];
}