diff --git a/.github/instructions/nx.instructions.md b/.github/instructions/nx.instructions.md
index 4af8bc88b..60e93b672 100644
--- a/.github/instructions/nx.instructions.md
+++ b/.github/instructions/nx.instructions.md
@@ -27,6 +27,6 @@ If the user wants to generate something, use the following flow:
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
-undefined
+
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2b63b6d10..aab4c8d4e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -30,6 +30,9 @@
}
],
"github.copilot.chat.codeGeneration.instructions": [
+ {
+ "file": ".vscode/llms/angular.txt"
+ },
{
"file": "docs/tech-stack.md"
},
diff --git a/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts b/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts
new file mode 100644
index 000000000..b478f363f
--- /dev/null
+++ b/apps/isa-app/stories/ui/expandable/expandable-directives.stories.ts
@@ -0,0 +1,49 @@
+import { Component, Input } from '@angular/core';
+import { ButtonComponent } from '@isa/ui/buttons';
+import { ExpandableDirectives } from '@isa/ui/expandable';
+import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
+
+@Component({
+ selector: 'app-expandable-directives',
+ template: `
+
+
+
Expanded Content
+
Collapsed Content
+
+ `,
+ standalone: true,
+ imports: [ExpandableDirectives, ButtonComponent],
+})
+class ExpandableDirectivesComponent {
+ @Input()
+ isExpanded = false;
+}
+
+const meta: Meta = {
+ title: 'ui/expandable/Expandable',
+ component: ExpandableDirectivesComponent,
+ argTypes: {
+ isExpanded: {
+ control: 'boolean',
+ description: 'Controls the expanded state of the section',
+ table: {
+ defaultValue: { summary: 'false' },
+ },
+ },
+ },
+ args: {
+ isExpanded: false,
+ },
+ render: (args) => ({
+ props: args,
+ template: ``,
+ }),
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+};
diff --git a/libs/common/data-access/src/index.ts b/libs/common/data-access/src/index.ts
index 0fb10966f..b2d994353 100644
--- a/libs/common/data-access/src/index.ts
+++ b/libs/common/data-access/src/index.ts
@@ -1,2 +1,3 @@
export * from './lib/errors';
export * from './lib/models';
+export * from './lib/operators';
diff --git a/libs/common/data-access/src/lib/operators/index.ts b/libs/common/data-access/src/lib/operators/index.ts
new file mode 100644
index 000000000..5c6ce6584
--- /dev/null
+++ b/libs/common/data-access/src/lib/operators/index.ts
@@ -0,0 +1 @@
+export * from './take-until-aborted';
diff --git a/libs/common/data-access/src/lib/operators/take-until-aborted.ts b/libs/common/data-access/src/lib/operators/take-until-aborted.ts
new file mode 100644
index 000000000..a2461f805
--- /dev/null
+++ b/libs/common/data-access/src/lib/operators/take-until-aborted.ts
@@ -0,0 +1,50 @@
+import { Observable, fromEvent } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+/**
+ * Creates an Observable that emits when an AbortSignal is aborted.
+ *
+ * @param signal - The AbortSignal instance to listen to
+ * @returns An Observable that emits and completes when the signal is aborted
+ */
+export const fromAbortSignal = (signal: AbortSignal): Observable => {
+ // If the signal is already aborted, return an Observable that immediately completes
+ if (signal.aborted) {
+ return new Observable((subscriber) => {
+ subscriber.complete();
+ });
+ }
+
+ // Otherwise, create an Observable from the abort event
+ return new Observable((subscriber) => {
+ const abortHandler = () => {
+ subscriber.next();
+ subscriber.complete();
+ };
+
+ // Listen for the 'abort' event
+ signal.addEventListener('abort', abortHandler);
+
+ // Clean up the event listener when the Observable is unsubscribed
+ return () => {
+ signal.removeEventListener('abort', abortHandler);
+ };
+ });
+};
+
+/**
+ * Operator that completes the source Observable when the provided AbortSignal is aborted.
+ * Similar to takeUntil, but works with AbortSignal instead of an Observable.
+ *
+ * @param signal - The AbortSignal instance that will trigger completion when aborted
+ * @returns An Observable that completes when the source completes or when the signal is aborted
+ */
+export const takeUntilAborted =
+ (signal: AbortSignal) =>
+ (source: Observable): Observable => {
+ // Convert the AbortSignal to an Observable
+ const aborted$ = fromAbortSignal(signal);
+
+ // Use the standard takeUntil operator with our abort Observable
+ return source.pipe(takeUntil(aborted$));
+ };
diff --git a/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts b/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts
deleted file mode 100644
index b2431f9f3..000000000
--- a/libs/common/print/src/lib/print-dialog/print-dialog.component.spec.ts
+++ /dev/null
@@ -1,290 +0,0 @@
-import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
-import {
- PrintDialogComponent,
- PrinterDialogData,
-} from './print-dialog.component';
-import { ButtonComponent } from '@isa/ui/buttons';
-import { ListboxDirective, ListboxItemDirective } from '@isa/ui/input-controls';
-import { MockComponent, MockDirective } from 'ng-mocks';
-import { Printer } from '../models';
-import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
-
-describe('PrintDialogComponent', () => {
- let spectator: Spectator;
- let component: PrintDialogComponent;
-
- // Mock printers for testing
- const mockPrinters: Printer[] = [
- {
- key: 'printer1',
- value: 'Printer 1',
- selected: false,
- enabled: true,
- description: 'First test printer',
- },
- {
- key: 'printer2',
- value: 'Printer 2',
- selected: true,
- enabled: true,
- description: 'Second test printer',
- },
- {
- key: 'printer3',
- value: 'Printer 3',
- selected: false,
- enabled: false,
- description: 'Disabled test printer',
- },
- ];
-
- // Mock print function
- const mockPrintFn = jest.fn().mockResolvedValue(undefined);
-
- // Default dialog data
- const defaultData: PrinterDialogData = {
- printers: mockPrinters,
- print: mockPrintFn,
- };
-
- // Mock DialogRef
- const mockDialogRef = {
- close: jest.fn(),
- };
-
- const createComponent = createComponentFactory({
- component: PrintDialogComponent,
- declarations: [
- MockComponent(ButtonComponent),
- MockDirective(ListboxDirective),
- MockDirective(ListboxItemDirective),
- ],
- providers: [
- { provide: DialogRef, useValue: mockDialogRef },
- { provide: DIALOG_DATA, useValue: defaultData },
- ],
- detectChanges: false,
- });
-
- beforeEach(() => {
- // Reset mocks
- mockPrintFn.mockClear();
- mockDialogRef.close.mockClear();
-
- // Create component without providing data prop since we provide it via DIALOG_DATA
- spectator = createComponent();
-
- component = spectator.component;
- spectator.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- it('should initialize with the selected printer', () => {
- // Assert
- expect(component.printer()).toEqual(mockPrinters[1]); // The one with selected: true
- });
-
- it('should compute selected array correctly with a printer', () => {
- // Arrange
- const selectedPrinter = mockPrinters[0];
- component.printer.set(selectedPrinter);
-
- // Act
- const result = component.selected();
-
- // Assert
- expect(result).toEqual([selectedPrinter]);
- });
-
- it('should compute selected array as empty when no printer is selected', () => {
- // Arrange
- component.printer.set(undefined);
-
- // Act
- const result = component.selected();
-
- // Assert
- expect(result).toEqual([]);
- });
-
- it('should compute canPrint as true when printer is selected and not printing', () => {
- // Arrange
- component.printer.set(mockPrinters[0]);
- component.printing.set(false);
-
- // Act
- const result = component.canPrint();
-
- // Assert
- expect(result).toBe(true);
- });
-
- it('should compute canPrint as false when no printer is selected', () => {
- // Arrange
- component.printer.set(undefined);
- component.printing.set(false);
-
- // Act
- const result = component.canPrint();
-
- // Assert
- expect(result).toBe(false);
- });
-
- it('should compute canPrint as false when printing is in progress', () => {
- // Arrange
- component.printer.set(mockPrinters[0]);
- component.printing.set(true);
-
- // Act
- const result = component.canPrint();
-
- // Assert
- expect(result).toBe(false);
- });
-
- it('should compare printers by key', () => {
- // Arrange
- const printer1 = { ...mockPrinters[0] };
- const printer2 = { ...mockPrinters[0] }; // Same key as printer1
- const printer3 = { ...mockPrinters[1] }; // Different key
-
- // Act & Assert
- expect(component.compareWith(printer1, printer2)).toBe(true);
- expect(component.compareWith(printer1, printer3)).toBe(false);
- });
-
- it('should select a printer and clear error', () => {
- // Arrange
- const initialError = new Error('Test error');
- component.error.set(initialError);
- const printer = mockPrinters[0];
-
- // Act
- component.select(printer);
-
- // Assert
- expect(component.printer()).toEqual(printer);
- expect(component.error()).toBeUndefined();
- });
-
- it('should not print when canPrint is false', async () => {
- // Arrange
- component.printer.set(undefined); // Makes canPrint() false
- const closeSpy = jest.spyOn(component, 'close');
-
- // Act
- await component.print();
-
- // Assert
- expect(mockPrintFn).not.toHaveBeenCalled();
- expect(closeSpy).not.toHaveBeenCalled();
- expect(component.printing()).toBe(false);
- });
- it('should print and close dialog when print succeeds', async () => {
- // Arrange
- const selectedPrinter = mockPrinters[0];
- component.printer.set(selectedPrinter);
- const closeSpy = jest.spyOn(component, 'close');
-
- // Act
- await component.print();
-
- // Assert
- // The printing flag stays true when success happens and dialog is closed
- expect(component.printing()).toBe(true);
- expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
- expect(closeSpy).toHaveBeenCalledWith({ printer: selectedPrinter });
- });
-
- it('should handle print errors', async () => {
- // Arrange
- const selectedPrinter = mockPrinters[0];
- component.printer.set(selectedPrinter);
- const testError = new Error('Print failed');
- mockPrintFn.mockRejectedValueOnce(testError);
- const closeSpy = jest.spyOn(component, 'close');
-
- // Act
- await component.print();
-
- // Assert
- expect(component.printing()).toBe(false); // Reset to false after error
- expect(mockPrintFn).toHaveBeenCalledWith(selectedPrinter);
- expect(closeSpy).not.toHaveBeenCalled();
- expect(component.error()).toBe(testError);
- });
-
- it('should format Error objects correctly', () => {
- // Arrange
- const errorMessage = 'Test error message';
- const testError = new Error(errorMessage);
-
- // Act
- const result = component.formatError(testError);
-
- // Assert
- expect(result).toBe(errorMessage);
- });
-
- it('should format string errors correctly', () => {
- // Arrange
- const errorMessage = 'Test error message';
-
- // Act
- const result = component.formatError(errorMessage);
-
- // Assert
- expect(result).toBe(errorMessage);
- });
-
- it('should format unknown errors correctly', () => {
- // Arrange
- const unknownError = { something: 'wrong' };
-
- // Act
- const result = component.formatError(unknownError);
-
- // Assert
- expect(result).toBe('Unbekannter Fehler');
- });
-
- it('should show error message in template when error exists', () => {
- // Arrange
- const errorMessage = 'Display this error';
- component.error.set(errorMessage);
- spectator.detectChanges();
-
- // Act
- const errorElement = spectator.query('.text-isa-accent-red');
-
- // Assert
- expect(errorElement).toHaveText(errorMessage);
- });
-
- it('should display printers in the listbox', () => {
- // Arrange & Act
- spectator.detectChanges();
- const listboxItems = spectator.queryAll('button[uiListboxItem]');
-
- // Assert
- expect(listboxItems.length).toBe(mockPrinters.length);
- expect(listboxItems[0]).toHaveText(mockPrinters[0].value);
- expect(listboxItems[1]).toHaveText(mockPrinters[1].value);
- expect(listboxItems[2]).toHaveText(mockPrinters[2].value);
- });
-
- it('should call close with undefined printer when cancel button is clicked', () => {
- // Arrange
- const closeSpy = jest.spyOn(component, 'close');
-
- // Act
- spectator.click('button[color="secondary"]');
-
- // Assert
- expect(closeSpy).toHaveBeenCalledWith({ printer: undefined });
- });
-});
diff --git a/libs/oms/data-access/src/index.ts b/libs/oms/data-access/src/index.ts
index ab880af68..b249bfe1b 100644
--- a/libs/oms/data-access/src/index.ts
+++ b/libs/oms/data-access/src/index.ts
@@ -11,6 +11,7 @@
* - Schemas: Validation schemas for ensuring data integrity
* - Return Process: Question flows and validation for return processing
* - Error handling: Specialized error types for OMS operations
+ * - Operators: Custom RxJS operators for OMS-specific use cases
*/
export * from './lib/errors';
@@ -19,4 +20,5 @@ export * from './lib/models';
export * from './lib/helpers/return-process';
export * from './lib/schemas';
export * from './lib/services';
+export * from './lib/operators';
export * from './lib/stores';
diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts
index 03b580e88..4d3cdab72 100644
--- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts
+++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts
@@ -9,8 +9,12 @@ import {
describe('CreateReturnProcessError', () => {
const params = {
processId: 123,
- receipt: { id: 321 } as Receipt,
- items: [] as ReceiptItem[],
+ returns: [
+ {
+ receipt: { id: 321 } as Receipt,
+ items: [] as ReceiptItem[],
+ },
+ ],
};
it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => {
diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts
index bf9317c16..54f4414da 100644
--- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts
+++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts
@@ -78,8 +78,7 @@ export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PRO
public readonly reason: CreateReturnProcessErrorReason,
public readonly params: {
processId: number;
- receipt: Receipt;
- items: ReceiptItem[];
+ returns: { receipt: Receipt; items: ReceiptItem[] }[];
},
) {
super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]);
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts
new file mode 100644
index 000000000..1c890ff03
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.spec.ts
@@ -0,0 +1,73 @@
+import { canReturnReceiptItem } from './can-return-receipt-item.helper';
+import { ReceiptItem } from '../../models/receipt-item';
+import { Product } from '../../models/product';
+import { Quantity } from '../../models/quantity';
+
+describe('canReturnReceiptItem', () => {
+ const product: Product = {
+ name: 'Test Product',
+ contributors: 'Author',
+ catalogProductNumber: '123',
+ ean: '1234567890123',
+ format: 'Hardcover',
+ formatDetail: 'Detail',
+ volume: '1',
+ manufacturer: 'Test Publisher',
+ };
+ const quantity: Quantity = { quantity: 1 };
+
+ const baseItem: ReceiptItem = {
+ id: 1,
+ product,
+ quantity,
+ receiptNumber: 'R-001',
+ actions: [],
+ };
+
+ it('should return false if actions property is missing', () => {
+ const item = { ...baseItem };
+ delete (item as { actions?: unknown }).actions;
+ expect(canReturnReceiptItem(item as ReceiptItem)).toBe(false);
+ });
+
+ it('should return false if canReturn action is missing', () => {
+ const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
+ expect(canReturnReceiptItem(item)).toBe(false);
+ });
+
+ it('should return false if canReturn action value is falsy', () => {
+ const item = { ...baseItem, actions: [{ key: 'canReturn', value: '' }] };
+ // coerceBooleanProperty('') returns true, so this should be true
+ expect(canReturnReceiptItem(item)).toBe(true);
+ const itemZero = {
+ ...baseItem,
+ actions: [{ key: 'canReturn', value: '0' }],
+ };
+ // coerceBooleanProperty('0') returns true (string '0' is truthy)
+ expect(canReturnReceiptItem(itemZero)).toBe(true);
+ });
+
+ it('should return true if canReturn action value is truthy', () => {
+ const item = {
+ ...baseItem,
+ actions: [{ key: 'canReturn', value: 'true' }],
+ };
+ expect(canReturnReceiptItem(item)).toBe(true);
+ });
+
+ it('should coerce canReturn action value to boolean', () => {
+ const item = { ...baseItem, actions: [{ key: 'canReturn', value: '1' }] };
+ expect(canReturnReceiptItem(item)).toBe(true);
+ const itemFalse = {
+ ...baseItem,
+ actions: [{ key: 'canReturn', value: '' }],
+ };
+ expect(canReturnReceiptItem(itemFalse)).toBe(true);
+ const itemStringZero = {
+ ...baseItem,
+ actions: [{ key: 'canReturn', value: '0' }],
+ };
+ // coerceBooleanProperty('0') returns true
+ expect(canReturnReceiptItem(itemStringZero)).toBe(true);
+ });
+});
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts
new file mode 100644
index 000000000..81f27e6ff
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/can-return-receipt-item.helper.ts
@@ -0,0 +1,25 @@
+import { coerceBooleanProperty } from '@angular/cdk/coercion';
+import { ReceiptItem } from '../../models';
+import { getReceiptItemAction } from './get-receipt-item-action.helper';
+
+/**
+ * Determines if a receipt item can be returned.
+ *
+ * @param receiptItem - The receipt item to check for return eligibility. Must have an 'actions' property (array of action objects).
+ * @returns {boolean} True if the item has a 'canReturn' action with a truthy value (coerced to boolean), otherwise false.
+ *
+ * @remarks
+ * - Returns false if the 'actions' property is missing or not an array.
+ * - Returns false if the 'canReturn' action is not present in the actions array.
+ * - Uses Angular's coerceBooleanProperty to interpret the action value.
+ */
+export function canReturnReceiptItem(receiptItem: ReceiptItem): boolean {
+ if (!receiptItem.actions) {
+ return false;
+ }
+ const canReturnAction = getReceiptItemAction(receiptItem, 'canReturn');
+ if (!canReturnAction) {
+ return false;
+ }
+ return coerceBooleanProperty(canReturnAction.value);
+}
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts
new file mode 100644
index 000000000..ba0a15a71
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.spec.ts
@@ -0,0 +1,55 @@
+import { getReceiptItemAction } from './get-receipt-item-action.helper';
+import { ReceiptItem } from '../../models/receipt-item';
+import { Product } from '../../models/product';
+import { Quantity } from '../../models/quantity';
+
+describe('getReceiptItemAction', () => {
+ const product: Product = {
+ name: 'Test Product',
+ contributors: 'Author',
+ catalogProductNumber: '123',
+ ean: '1234567890123',
+ format: 'Hardcover',
+ formatDetail: 'Detail',
+ volume: '1',
+ manufacturer: 'Test Publisher',
+ };
+ const quantity: Quantity = { quantity: 1 };
+
+ const baseItem: ReceiptItem = {
+ id: 1,
+ product,
+ quantity,
+ receiptNumber: 'R-001',
+ actions: [],
+ };
+
+ it('should return undefined if actions property is missing', () => {
+ const item = { ...baseItem };
+ delete (item as { actions?: unknown }).actions;
+ expect(
+ getReceiptItemAction(item as ReceiptItem, 'canReturn'),
+ ).toBeUndefined();
+ });
+
+ it('should return undefined if no action with the given key exists', () => {
+ const item = { ...baseItem, actions: [{ key: 'other', value: 'true' }] };
+ expect(getReceiptItemAction(item, 'canReturn')).toBeUndefined();
+ });
+
+ it('should return the action object if the key exists', () => {
+ const action = { key: 'canReturn', value: 'true' };
+ const item = {
+ ...baseItem,
+ actions: [action, { key: 'other', value: 'false' }],
+ };
+ expect(getReceiptItemAction(item, 'canReturn')).toBe(action);
+ });
+
+ it('should return the first matching action if multiple exist', () => {
+ const action1 = { key: 'canReturn', value: 'true' };
+ const action2 = { key: 'canReturn', value: 'false' };
+ const item = { ...baseItem, actions: [action1, action2] };
+ expect(getReceiptItemAction(item, 'canReturn')).toBe(action1);
+ });
+});
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts
new file mode 100644
index 000000000..983a4bac0
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-action.helper.ts
@@ -0,0 +1,24 @@
+import { KeyValueDTOOfStringAndString } from '@generated/swagger/oms-api';
+import { ReceiptItem } from '../../models';
+
+/**
+ * Retrieves a specific action object from a receipt item's actions array by key.
+ *
+ * @param receiptItem - The receipt item containing the actions array.
+ * @param actionKey - The key of the action to retrieve.
+ * @returns The action object with the specified key, or undefined if not found or if actions are missing.
+ *
+ * @remarks
+ * - Returns undefined if the 'actions' property is missing or not an array.
+ * - Returns undefined if no action with the given key exists.
+ */
+export function getReceiptItemAction(
+ receiptItem: ReceiptItem,
+ actionKey: string,
+): KeyValueDTOOfStringAndString | undefined {
+ if ('actions' in receiptItem === false) {
+ return undefined;
+ }
+
+ return receiptItem.actions?.find((a) => a.key === actionKey);
+}
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts
new file mode 100644
index 000000000..fd4e9d462
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.spec.ts
@@ -0,0 +1,55 @@
+import { getReceiptItemProductCategory } from './get-receipt-item-product-category.helper';
+import { ReceiptItem } from '../../models/receipt-item';
+import { Product } from '../../models/product';
+import { Quantity } from '../../models/quantity';
+import { ProductCategory } from '../../questions/constants';
+
+describe('getReceiptItemProductCategory', () => {
+ const product: Product = {
+ name: 'Test Product',
+ contributors: 'Author',
+ catalogProductNumber: '123',
+ ean: '1234567890123',
+ format: 'Hardcover',
+ formatDetail: 'Detail',
+ volume: '1',
+ manufacturer: 'Test Publisher',
+ };
+ const quantity: Quantity = { quantity: 1 };
+
+ const baseItem: ReceiptItem = {
+ id: 1,
+ product,
+ quantity,
+ receiptNumber: 'R-001',
+ features: {},
+ };
+
+ it('should return ProductCategory.Unknown if features property is missing', () => {
+ const item = { ...baseItem };
+ delete (item as { features?: unknown }).features;
+ expect(getReceiptItemProductCategory(item as ReceiptItem)).toBe(
+ ProductCategory.Unknown,
+ );
+ });
+
+ it('should return ProductCategory.Unknown if category is not set', () => {
+ const item = { ...baseItem, features: {} };
+ expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
+ });
+
+ it('should return the category if set in features', () => {
+ const item = {
+ ...baseItem,
+ features: { category: ProductCategory.BookCalendar },
+ };
+ expect(getReceiptItemProductCategory(item)).toBe(
+ ProductCategory.BookCalendar,
+ );
+ });
+
+ it('should return ProductCategory.Unknown if category is set to a falsy value', () => {
+ const item = { ...baseItem, features: { category: '' } };
+ expect(getReceiptItemProductCategory(item)).toBe(ProductCategory.Unknown);
+ });
+});
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts
new file mode 100644
index 000000000..9e6075560
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/get-receipt-item-product-category.helper.ts
@@ -0,0 +1,23 @@
+import { ReceiptItem } from '../../models';
+import { ProductCategory } from '../../questions';
+
+/**
+ * Retrieves the product category for a given receipt item.
+ *
+ * @param item - The receipt item to extract the product category from.
+ * @returns The product category if present in the item's features; otherwise, ProductCategory.Unknown.
+ *
+ * @remarks
+ * - Returns ProductCategory.Unknown if the 'features' property is missing or does not contain a 'category'.
+ * - Casts the 'category' feature to ProductCategory if present.
+ */
+export function getReceiptItemProductCategory(
+ item: ReceiptItem,
+): ProductCategory {
+ if (!item.features) {
+ return ProductCategory.Unknown;
+ }
+ return (
+ (item.features['category'] as ProductCategory) || ProductCategory.Unknown
+ );
+}
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/index.ts b/libs/oms/data-access/src/lib/helpers/return-process/index.ts
index 8c79f7250..2c034a299 100644
--- a/libs/oms/data-access/src/lib/helpers/return-process/index.ts
+++ b/libs/oms/data-access/src/lib/helpers/return-process/index.ts
@@ -1,10 +1,14 @@
export * from './active-return-process-questions.helper';
export * from './all-return-process-questions-answered.helper';
export * from './calculate-longest-question-depth.helper';
-export * from './get-next-question.helper';
-export * from './get-return-info.helper';
+export * from './can-return-receipt-item.helper';
export * from './eligible-for-return.helper';
+export * from './get-next-question.helper';
+export * from './get-receipt-item-action.helper';
+export * from './get-receipt-item-product-category.helper';
+export * from './get-return-info.helper';
export * from './get-return-process-questions.helper';
-export * from './return-receipt-values-mapping.helper';
-export * from './return-details-mapping.helper';
export * from './get-tolino-questions.helper';
+export * from './receipt-item-has-category.helper';
+export * from './return-details-mapping.helper';
+export * from './return-receipt-values-mapping.helper';
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts
new file mode 100644
index 000000000..ee356f33f
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.spec.ts
@@ -0,0 +1,62 @@
+import { receiptItemHasCategory } from './receipt-item-has-category.helper';
+import { ReceiptItem } from '../../models/receipt-item';
+import { Product } from '../../models/product';
+import { Quantity } from '../../models/quantity';
+import { ProductCategory } from '../../questions/constants';
+
+describe('receiptItemHasCategory', () => {
+ const product: Product = {
+ name: 'Test Product',
+ contributors: 'Author',
+ catalogProductNumber: '123',
+ ean: '1234567890123',
+ format: 'Hardcover',
+ formatDetail: 'Detail',
+ volume: '1',
+ manufacturer: 'Test Publisher',
+ };
+ const quantity: Quantity = { quantity: 1 };
+
+ const baseItem: ReceiptItem = {
+ id: 1,
+ product,
+ quantity,
+ receiptNumber: 'R-001',
+ features: {},
+ };
+
+ it('should return false if features property is missing', () => {
+ const item = { ...baseItem };
+ delete (item as { features?: unknown }).features;
+ expect(
+ receiptItemHasCategory(item as ReceiptItem, ProductCategory.BookCalendar),
+ ).toBe(false);
+ });
+
+ it('should return false if category does not match', () => {
+ const item = {
+ ...baseItem,
+ features: { category: ProductCategory.Tolino },
+ };
+ expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
+ false,
+ );
+ });
+
+ it('should return true if category matches', () => {
+ const item = {
+ ...baseItem,
+ features: { category: ProductCategory.BookCalendar },
+ };
+ expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
+ true,
+ );
+ });
+
+ it('should return false if category is missing in features', () => {
+ const item = { ...baseItem, features: {} };
+ expect(receiptItemHasCategory(item, ProductCategory.BookCalendar)).toBe(
+ false,
+ );
+ });
+});
diff --git a/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts
new file mode 100644
index 000000000..bbc4a0a7f
--- /dev/null
+++ b/libs/oms/data-access/src/lib/helpers/return-process/receipt-item-has-category.helper.ts
@@ -0,0 +1,24 @@
+import { ReceiptItem } from '../../models';
+import { ProductCategory } from '../../questions';
+
+/**
+ * Checks if a receipt item has the specified product category.
+ *
+ * @param item - The receipt item to check.
+ * @param category - The product category to compare against.
+ * @returns True if the item's features contain the specified category, otherwise false.
+ *
+ * @remarks
+ * - Returns false if the 'features' property is missing.
+ * - Performs a strict equality check between the item's category and the provided category.
+ */
+export function receiptItemHasCategory(
+ item: ReceiptItem,
+ category: ProductCategory,
+) {
+ if (!item.features) {
+ return false;
+ }
+ const itemCategory = item.features['category'];
+ return itemCategory === category;
+}
diff --git a/libs/oms/data-access/src/lib/models/index.ts b/libs/oms/data-access/src/lib/models/index.ts
index 6bd66f572..0e6d3d6e4 100644
--- a/libs/oms/data-access/src/lib/models/index.ts
+++ b/libs/oms/data-access/src/lib/models/index.ts
@@ -5,6 +5,7 @@ export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
+export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
@@ -17,5 +18,6 @@ export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
+export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';
diff --git a/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts b/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts
new file mode 100644
index 000000000..49de292e9
--- /dev/null
+++ b/libs/oms/data-access/src/lib/models/receipt-item-list-item.ts
@@ -0,0 +1,10 @@
+import { ReceiptItemListItemDTO } from '@generated/swagger/oms-api';
+import { Product } from './product';
+import { Quantity } from './quantity';
+
+export interface ReceiptItemListItem extends ReceiptItemListItemDTO {
+ id: number;
+ product: Product;
+ quantity: Quantity;
+ receiptItemNumber: string;
+}
diff --git a/libs/oms/data-access/src/lib/models/receipt-item.ts b/libs/oms/data-access/src/lib/models/receipt-item.ts
index 03d150730..ca691f5f0 100644
--- a/libs/oms/data-access/src/lib/models/receipt-item.ts
+++ b/libs/oms/data-access/src/lib/models/receipt-item.ts
@@ -6,4 +6,5 @@ export interface ReceiptItem extends ReceiptItemDTO {
id: number;
product: Product;
quantity: Quantity;
+ receiptNumber: string;
}
diff --git a/libs/oms/data-access/src/lib/models/receipt-list-item.ts b/libs/oms/data-access/src/lib/models/receipt-list-item.ts
index 77a6578b9..04ac867e8 100644
--- a/libs/oms/data-access/src/lib/models/receipt-list-item.ts
+++ b/libs/oms/data-access/src/lib/models/receipt-list-item.ts
@@ -2,4 +2,5 @@ import { ReceiptListItemDTO } from '@generated/swagger/oms-api';
export interface ReceiptListItem extends ReceiptListItemDTO {
id: number;
+ receiptNumber: string;
}
diff --git a/libs/oms/data-access/src/lib/models/receipt.ts b/libs/oms/data-access/src/lib/models/receipt.ts
index 428f536a0..d36e8a035 100644
--- a/libs/oms/data-access/src/lib/models/receipt.ts
+++ b/libs/oms/data-access/src/lib/models/receipt.ts
@@ -2,9 +2,11 @@ import { ReceiptDTO } from '@generated/swagger/oms-api';
import { EntityContainer } from '@isa/common/data-access';
import { ReceiptItem } from './receipt-item';
import { Buyer } from './buyer';
+import { ShippingAddress2 } from './shipping-address-2';
export interface Receipt extends ReceiptDTO {
id: number;
items: EntityContainer[];
buyer: Buyer;
+ shipping?: ShippingAddress2;
}
diff --git a/libs/oms/data-access/src/lib/models/shipping-address-2.ts b/libs/oms/data-access/src/lib/models/shipping-address-2.ts
new file mode 100644
index 000000000..4ad06037a
--- /dev/null
+++ b/libs/oms/data-access/src/lib/models/shipping-address-2.ts
@@ -0,0 +1,3 @@
+import { ShippingAddressDTO2 } from '@generated/swagger/oms-api';
+
+export type ShippingAddress2 = ShippingAddressDTO2;
diff --git a/libs/oms/data-access/src/lib/operators/index.ts b/libs/oms/data-access/src/lib/operators/index.ts
new file mode 100644
index 000000000..5c6ce6584
--- /dev/null
+++ b/libs/oms/data-access/src/lib/operators/index.ts
@@ -0,0 +1 @@
+export * from './take-until-aborted';
diff --git a/libs/oms/data-access/src/lib/operators/take-until-aborted.ts b/libs/oms/data-access/src/lib/operators/take-until-aborted.ts
new file mode 100644
index 000000000..8b522afe9
--- /dev/null
+++ b/libs/oms/data-access/src/lib/operators/take-until-aborted.ts
@@ -0,0 +1,50 @@
+import { Observable } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+/**
+ * Creates an Observable that emits when an AbortSignal is aborted.
+ *
+ * @param signal - The AbortSignal instance to listen to
+ * @returns An Observable that emits and completes when the signal is aborted
+ */
+export const fromAbortSignal = (signal: AbortSignal): Observable => {
+ // If the signal is already aborted, return an Observable that immediately completes
+ if (signal.aborted) {
+ return new Observable((subscriber) => {
+ subscriber.complete();
+ });
+ }
+
+ // Otherwise, create an Observable from the abort event
+ return new Observable((subscriber) => {
+ const abortHandler = () => {
+ subscriber.next();
+ subscriber.complete();
+ };
+
+ // Listen for the 'abort' event
+ signal.addEventListener('abort', abortHandler);
+
+ // Clean up the event listener when the Observable is unsubscribed
+ return () => {
+ signal.removeEventListener('abort', abortHandler);
+ };
+ });
+};
+
+/**
+ * Operator that completes the source Observable when the provided AbortSignal is aborted.
+ * Similar to takeUntil, but works with AbortSignal instead of an Observable.
+ *
+ * @param signal - The AbortSignal instance that will trigger completion when aborted
+ * @returns An Observable that completes when the source completes or when the signal is aborted
+ */
+export const takeUntilAborted =
+ (signal: AbortSignal) =>
+ (source: Observable): Observable => {
+ // Convert the AbortSignal to an Observable
+ const aborted$ = fromAbortSignal(signal);
+
+ // Use the standard takeUntil operator with our abort Observable
+ return source.pipe(takeUntil(aborted$));
+ };
diff --git a/libs/oms/data-access/src/lib/questions/constants.ts b/libs/oms/data-access/src/lib/questions/constants.ts
index 2d671cad5..115408cad 100644
--- a/libs/oms/data-access/src/lib/questions/constants.ts
+++ b/libs/oms/data-access/src/lib/questions/constants.ts
@@ -5,6 +5,7 @@ import { ReturnProcessQuestionKey } from '../models';
* Constants for product categories used in the return process.
*/
export const ProductCategory = {
+ Unknown: 'unknown',
BookCalendar: 'Buch/Kalender',
TonDatentraeger: 'Ton-/Datenträger',
SpielwarenPuzzle: 'Spielwaren/Puzzle',
diff --git a/libs/oms/data-access/src/lib/questions/registry.ts b/libs/oms/data-access/src/lib/questions/registry.ts
index a4fa21aa8..ecd104298 100644
--- a/libs/oms/data-access/src/lib/questions/registry.ts
+++ b/libs/oms/data-access/src/lib/questions/registry.ts
@@ -26,4 +26,5 @@ export const CategoryQuestions: Record<
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
[ProductCategory.Tolino]: tolinoQuestions,
+ [ProductCategory.Unknown]: [],
};
diff --git a/libs/oms/data-access/src/lib/services/return-can-return.service.ts b/libs/oms/data-access/src/lib/services/return-can-return.service.ts
index 8ef9041d5..3aa54fe8d 100644
--- a/libs/oms/data-access/src/lib/services/return-can-return.service.ts
+++ b/libs/oms/data-access/src/lib/services/return-can-return.service.ts
@@ -12,6 +12,7 @@ import {
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isReturnProcessTypeGuard } from '../guards';
+import { takeUntilAborted } from '@isa/common/data-access';
/**
* Service for determining if a return process can proceed based on
@@ -35,14 +36,20 @@ export class ReturnCanReturnService {
* @param returnProcess - The return process object to evaluate.
* @returns A promise resolving to a CanReturn result or undefined if the process should continue.
*/
- async canReturn(returnProcess: ReturnProcess): Promise;
+ async canReturn(
+ returnProcess: ReturnProcess,
+ abortSignal?: AbortSignal,
+ ): Promise;
/**
* Determines if a return can proceed based on mapped receipt values.
*
* @param returnValues - The mapped return receipt values.
* @returns A promise resolving to a CanReturn result.
*/
- async canReturn(returnValues: ReturnReceiptValues): Promise;
+ async canReturn(
+ returnValues: ReturnReceiptValues,
+ abortSignal?: AbortSignal,
+ ): Promise;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
@@ -53,6 +60,7 @@ export class ReturnCanReturnService {
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
+ abortSignal?: AbortSignal,
): Promise {
let data: ReturnReceiptValues | undefined = undefined;
@@ -66,14 +74,20 @@ export class ReturnCanReturnService {
return undefined; // Prozess soll weitergehen, daher kein Error
}
+ let req$ = this.#receiptService.ReceiptCanReturn(
+ data as ReturnReceiptValuesDTO,
+ );
+
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
+ }
+
try {
return await firstValueFrom(
- this.#receiptService
- .ReceiptCanReturn(data as ReturnReceiptValuesDTO)
- .pipe(
- debounceTime(50),
- map((res) => res as CanReturn),
- ),
+ req$.pipe(
+ debounceTime(50),
+ map((res) => res as CanReturn),
+ ),
);
} catch (error) {
throw new Error(`ReceiptCanReturn failed: ${String(error)}`);
diff --git a/libs/oms/data-access/src/lib/services/return-details.service.spec.ts b/libs/oms/data-access/src/lib/services/return-details.service.spec.ts
index e7b8b1e93..15ccd1737 100644
--- a/libs/oms/data-access/src/lib/services/return-details.service.spec.ts
+++ b/libs/oms/data-access/src/lib/services/return-details.service.spec.ts
@@ -1,7 +1,10 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { ReturnDetailsService } from './return-details.service';
-import { ReceiptService } from '@generated/swagger/oms-api';
-import { of } from 'rxjs';
+import {
+ ReceiptService,
+ ResponseArgsOfReceiptDTO,
+} from '@generated/swagger/oms-api';
+import { of, NEVER } from 'rxjs';
import { FetchReturnDetails } from '../schemas';
import { Receipt } from '../models';
@@ -16,7 +19,7 @@ describe('ReturnDetailsService', () => {
spectator = createService();
});
- it('should fetch return details successfully', (done) => {
+ it('should fetch return details successfully', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { result: { id: 123, data: 'mockData' } };
@@ -24,14 +27,12 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
- spectator.service.fetchReturnDetails(mockParams).subscribe((result) => {
- // Assert
- expect(result).toEqual(mockResponse.result as Receipt);
- done();
- });
+ const result = await spectator.service.fetchReturnDetails(mockParams);
+
+ expect(result).toEqual(mockResponse.result as Receipt);
});
- it('should throw an error if API response contains an error', (done) => {
+ it('should throw an error if API response contains an error', async () => {
// Arrange
const mockParams: FetchReturnDetails = { receiptId: 123 };
const mockResponse: any = { error: 'API error' };
@@ -39,26 +40,72 @@ describe('ReturnDetailsService', () => {
receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
// Act
- spectator.service.fetchReturnDetails(mockParams).subscribe({
- error: (err) => {
- // Assert
- expect(err.message).toBe('Failed to fetch return details');
- done();
- },
- });
+ try {
+ await spectator.service.fetchReturnDetails(mockParams);
+ } catch (err) {
+ expect((err as Error).message).toBe('Failed to fetch return details');
+ }
});
- it('should throw an error if parameters are invalid', (done) => {
+ /**
+ * Should return undefined or throw if API returns an empty object.
+ */
+ it('should handle empty API response gracefully', async () => {
// Arrange
- const invalidParams: any = { receiptId: null };
+ const mockParams: FetchReturnDetails = { receiptId: 123 };
+ const mockResponse: ResponseArgsOfReceiptDTO = {
+ error: false,
+ result: undefined,
+ };
+ const receiptService = spectator.inject(ReceiptService);
+ receiptService.ReceiptGetReceipt.mockReturnValue(of(mockResponse));
+
+ // Act & Assert
+ await expect(
+ spectator.service.fetchReturnDetails(mockParams),
+ ).rejects.toThrow('Failed to fetch return details');
+ });
+
+ /**
+ * Should call ReceiptGetReceipt with correct parameters.
+ */
+ it('should call ReceiptGetReceipt with correct params', async () => {
+ // Arrange
+ const mockParams: FetchReturnDetails = { receiptId: 456 };
+ const mockResponse: ResponseArgsOfReceiptDTO = {
+ error: false,
+ result: { id: 456, data: 'mockData' } as unknown as Receipt,
+ };
+ const receiptService = spectator.inject(ReceiptService);
+ const spy = jest.spyOn(receiptService, 'ReceiptGetReceipt');
+ spy.mockReturnValue(of(mockResponse));
// Act
- spectator.service.fetchReturnDetails(invalidParams).subscribe({
- error: (err) => {
- // Assert
- expect(err).toBeTruthy();
- done();
- },
- });
+ await spectator.service.fetchReturnDetails(mockParams);
+
+ // Assert
+ expect(spy).toHaveBeenCalledWith({ ...mockParams, eagerLoading: 2 });
+ });
+
+ /**
+ * Should handle observable that never emits (simulate hanging request).
+ */
+ it('should handle observable that never emits', async () => {
+ // Arrange
+ const mockParams: FetchReturnDetails = { receiptId: 789 };
+ const receiptService = spectator.inject(ReceiptService);
+ // Simulate never emitting observable
+ receiptService.ReceiptGetReceipt.mockReturnValue(NEVER);
+
+ // Act & Assert
+ // Should timeout or hang, so we expect the promise not to resolve
+ // For test safety, wrap in a Promise.race with a timeout
+ const fetchPromise = spectator.service.fetchReturnDetails(mockParams);
+ const timeout = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Timeout')), 100),
+ );
+ await expect(Promise.race([fetchPromise, timeout])).rejects.toThrow(
+ 'Timeout',
+ );
});
});
diff --git a/libs/oms/data-access/src/lib/services/return-details.service.ts b/libs/oms/data-access/src/lib/services/return-details.service.ts
index f5286f48a..6965592ea 100644
--- a/libs/oms/data-access/src/lib/services/return-details.service.ts
+++ b/libs/oms/data-access/src/lib/services/return-details.service.ts
@@ -4,27 +4,43 @@ import {
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
-import { map, Observable, throwError } from 'rxjs';
+import { firstValueFrom } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
-import { Receipt, ReceiptItem } from '../models';
-import { CategoryQuestions } from '../questions';
+import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models';
+import { CategoryQuestions, ProductCategory } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
+import { takeUntilAborted } from '@isa/common/data-access';
+import { z } from 'zod';
+/**
+ * Service responsible for managing receipt return details and operations.
+ *
+ * This service provides functionality to:
+ * - Check if items are eligible for return
+ * - Fetch receipt details by receipt ID
+ * - Query receipts by customer email
+ * - Get available product categories for returns
+ */
@Injectable({ providedIn: 'root' })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
#returnCanReturnService = inject(ReturnCanReturnService);
-
/**
* Determines if a specific receipt item can be returned for a given category.
*
* @param params - The parameters for the return check.
* @param params.item - The receipt item to check.
* @param params.category - The product category to check against.
- * @returns A promise resolving to the result of the canReturn check.
+ * @param abortSignal - Optional AbortSignal to cancel the request.
+ * @returns A promise resolving to the result of the canReturn check, containing
+ * eligibility status and any relevant constraints or messages.
+ * @throws Will throw an error if the return check fails or is aborted.
*/
- async canReturn({ item, category }: { item: ReceiptItem; category: string }) {
+ async canReturn(
+ { item, category }: { item: ReceiptItem; category: ProductCategory },
+ abortSignal?: AbortSignal,
+ ): Promise {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
receiptItem: {
@@ -33,44 +49,107 @@ export class ReturnDetailsService {
category,
};
- return await this.#returnCanReturnService.canReturn(returnReceiptValues);
+ return this.#returnCanReturnService.canReturn(
+ returnReceiptValues,
+ abortSignal,
+ );
}
-
/**
* Gets all available product categories that have defined question sets.
*
- * @returns {KeyValue[]} Array of key-value pairs representing available categories.
+ * This method filters out the "Unknown" category and returns all other
+ * categories defined in the CategoryQuestions object.
+ *
+ * @returns {KeyValue[]} Array of key-value pairs representing
+ * available categories, where the key is the ProductCategory enum value
+ * and the value is the string representation.
*/
- availableCategories(): KeyValue[] {
- return Object.keys(CategoryQuestions).map((key) => {
- return { key, value: key };
+ availableCategories(): KeyValue[] {
+ const categories = Object.keys(CategoryQuestions).map((key) => {
+ return { key: key as ProductCategory, value: key };
});
- }
+ return categories.filter(
+ (category) => category.key !== ProductCategory.Unknown,
+ );
+ }
/**
* Fetches the return details for a specific receipt.
*
+ * This method retrieves detailed information about a receipt using its ID.
+ * The data is validated using Zod schema validation before making the API call.
+ *
* @param params - The parameters required to fetch the return details, including the receipt ID.
- * @returns An observable that emits the fetched receipt details.
+ * @param abortSignal - Optional AbortSignal that can be used to cancel the request.
+ * @returns A promise that resolves to the fetched receipt details.
* @throws Will throw an error if the parameters are invalid or if the API response contains an error.
*/
- fetchReturnDetails(params: FetchReturnDetails): Observable {
- try {
- const parsed = FetchReturnDetailsSchema.parse(params);
+ async fetchReturnDetails(
+ params: FetchReturnDetails,
+ abortSignal?: AbortSignal,
+ ): Promise {
+ const parsed = FetchReturnDetailsSchema.parse(params);
- return this.#receiptService
- .ReceiptGetReceipt({ receiptId: parsed.receiptId, eagerLoading: 2 })
- .pipe(
- map((res) => {
- if (res.error || !res.result) {
- throw new Error(res.message || 'Failed to fetch return details');
- }
+ let req$ = this.#receiptService.ReceiptGetReceipt({
+ receiptId: parsed.receiptId,
+ eagerLoading: 2,
+ });
- return res.result as Receipt;
- }),
- );
- } catch (error) {
- return throwError(() => error);
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
}
+
+ const res = await firstValueFrom(req$);
+
+ if (res.error || !res.result) {
+ throw new Error(res.message || 'Failed to fetch return details');
+ }
+
+ return res.result as Receipt;
+ }
+ /**
+ * Schema definition for email-based receipt query parameters.
+ * Validates that the email parameter is a properly formatted email address.
+ */
+ static FetchReceiptsEmailParamsSchema = z.object({
+ email: z.string().email(),
+ });
+
+ /**
+ * Fetches receipts associated with a specific email address.
+ *
+ * This method queries the receipt service for receipts that match the provided email address.
+ * The email is validated using Zod schema validation before making the API call.
+ *
+ * @param params - The parameters containing the email to search for.
+ * @param params.email - Email address to search for in receipt records.
+ * @param abortSignal - Optional AbortSignal that can be used to cancel the request.
+ * @returns A promise that resolves to an array of receipt list items matching the email.
+ * @throws Will throw an error if the email is invalid or if the API response contains an error.
+ */
+ async fetchReceiptsByEmail(
+ params: z.infer,
+ abortSignal?: AbortSignal,
+ ): Promise {
+ const { email } =
+ ReturnDetailsService.FetchReceiptsEmailParamsSchema.parse(params);
+
+ let req$ = this.#receiptService.ReceiptQueryReceipt({
+ queryToken: {
+ input: { qs: email },
+ filter: { receipt_type: '1;128;1024' },
+ },
+ });
+
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
+ }
+
+ const res = await firstValueFrom(req$);
+ if (res.error || !res.result) {
+ throw new Error(res.message || 'Failed to fetch return items by email');
+ }
+
+ return res.result as ReceiptListItem[];
}
}
diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts
index 1a28ceaf4..99923e377 100644
--- a/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts
+++ b/libs/oms/data-access/src/lib/stores/return-details.store.spec.ts
@@ -1,169 +1,57 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
-import { ReturnDetailsStore } from './return-details.store';
+import { receiptConfig, ReturnDetailsStore } from './return-details.store';
import { ReturnDetailsService } from '../services';
+import { SessionStorageProvider } from '@isa/core/storage';
+import { LoggingService } from '@isa/core/logging';
import { patchState } from '@ngrx/signals';
-import { AsyncResultStatus } from '@isa/common/data-access';
-import { addEntity } from '@ngrx/signals/entities';
-import { Receipt } from '../models';
-import { of, throwError } from 'rxjs';
+import { unprotected } from '@ngrx/signals/testing';
+import { Receipt, ReceiptItem } from '../models';
+import { addEntities } from '@ngrx/signals/entities';
describe('ReturnDetailsStore', () => {
const createService = createServiceFactory({
service: ReturnDetailsStore,
- mocks: [ReturnDetailsService],
+ mocks: [ReturnDetailsService, SessionStorageProvider, LoggingService],
});
- describe('Initialization', () => {
- it('should create an instance of ReturnDetailsStore', () => {
- const spectator = createService();
- expect(spectator.service).toBeTruthy();
- });
+ it('should create an instance of ReturnDetailsStore', () => {
+ const spectator = createService();
+ expect(spectator.service).toBeTruthy();
});
- describe('Entity Management', () => {
- describe('beforeFetch', () => {
- it('should create a new entity and set status to Pending if it does not exist', () => {
- const spectator = createService();
- const receiptId = 123;
- spectator.service.beforeFetch(receiptId);
-
- expect(spectator.service.entityMap()[123]).toEqual({
- id: receiptId,
- data: undefined,
- status: AsyncResultStatus.Pending,
- });
- });
-
- it('should update the existing entity status to Pending', () => {
- const spectator = createService();
- const receiptId = 123;
-
- const data = {};
-
- patchState(
- spectator.service as any,
- addEntity({ id: receiptId, data, status: AsyncResultStatus.Idle }),
- );
-
- spectator.service.beforeFetch(receiptId);
-
- expect(spectator.service.entityMap()[123]).toEqual({
- id: receiptId,
- data,
- status: AsyncResultStatus.Pending,
- });
- });
- });
-
- describe('fetchSuccess', () => {
- it('should update the entity with fetched data and set status to Success', () => {
- const spectator = createService();
- const receiptId = 123;
- const data: Receipt = {
- id: receiptId,
- items: [],
- buyer: { buyerNumber: '321' },
- };
-
- patchState(
- spectator.service as any,
- addEntity({
- id: receiptId,
- data: undefined,
- status: AsyncResultStatus.Pending,
- }),
- );
-
- spectator.service.fetchSuccess(receiptId, data);
-
- expect(spectator.service.entityMap()[123]).toEqual({
- id: receiptId,
- data,
- status: AsyncResultStatus.Success,
- });
- });
- });
-
- describe('fetchError', () => {
- it('should update the entity status to Error', () => {
- const spectator = createService();
- const receiptId = 123;
- const error = new Error('Fetch error');
-
- patchState(
- spectator.service as any,
- addEntity({
- id: receiptId,
- data: undefined,
- status: AsyncResultStatus.Pending,
- }),
- );
-
- spectator.service.fetchError(receiptId, error);
-
- const entity = spectator.service.entityMap()[123];
- expect(entity).toMatchObject({
- id: receiptId,
- status: AsyncResultStatus.Error,
- error,
- });
- });
- });
- });
-
- describe('fetch', () => {
- it('should call the service and update the store on success', () => {
+ describe('items', () => {
+ it('should return the items from the receiptsEntities', () => {
+ // Arrange
const spectator = createService();
- const receiptId = 123;
- const data: Receipt = {
- id: receiptId,
- items: [],
- buyer: { buyerNumber: '321' },
- };
- spectator.service.beforeFetch(receiptId);
- spectator
- .inject(ReturnDetailsService)
- .fetchReturnDetails.mockReturnValueOnce(of(data));
+ const receiptItems = [
+ { id: 1 } as ReceiptItem,
+ { id: 2 } as ReceiptItem,
+ { id: 3 } as ReceiptItem,
+ { id: 4 } as ReceiptItem,
+ ];
- spectator.service.fetch({ receiptId });
+ const receiptsEntities = [
+ {
+ id: 1,
+ items: [{ data: receiptItems[0] }, { data: receiptItems[1] }],
+ } as Receipt,
+ {
+ id: 2,
+ items: [{ data: receiptItems[2] }, { data: receiptItems[3] }],
+ } as Receipt,
+ ] as Receipt[];
- expect(
- spectator.inject(ReturnDetailsService).fetchReturnDetails,
- ).toHaveBeenCalledWith({
- receiptId,
- });
+ patchState(
+ unprotected(spectator.service),
+ addEntities(receiptsEntities, receiptConfig),
+ );
- expect(spectator.service.entityMap()[123]).toEqual({
- id: receiptId,
- data,
- status: AsyncResultStatus.Success,
- });
- });
+ // Act
+ const items = spectator.service.items();
- it('should handle errors and update the store accordingly', () => {
- const spectator = createService();
- const receiptId = 123;
- const error = new Error('Fetch error');
-
- spectator.service.beforeFetch(receiptId);
- spectator
- .inject(ReturnDetailsService)
- .fetchReturnDetails.mockReturnValueOnce(throwError(() => error));
-
- spectator.service.fetch({ receiptId });
-
- expect(
- spectator.inject(ReturnDetailsService).fetchReturnDetails,
- ).toHaveBeenCalledWith({
- receiptId,
- });
- const entity = spectator.service.entityMap()[123];
- expect(entity).toMatchObject({
- id: receiptId,
- status: AsyncResultStatus.Error,
- error,
- });
+ // Assert
+ expect(items).toEqual(receiptItems);
});
});
});
diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.ts b/libs/oms/data-access/src/lib/stores/return-details.store.ts
index ed9619417..b60dfbb2b 100644
--- a/libs/oms/data-access/src/lib/stores/return-details.store.ts
+++ b/libs/oms/data-access/src/lib/stores/return-details.store.ts
@@ -1,218 +1,267 @@
-import { patchState, signalStore, withMethods } from '@ngrx/signals';
-import { addEntity, updateEntity, withEntities } from '@ngrx/signals/entities';
-import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
-import { rxMethod } from '@ngrx/signals/rxjs-interop';
-import { pipe, switchMap, tap } from 'rxjs';
-import { inject } from '@angular/core';
-import { ReturnDetailsService } from '../services';
-import { tapResponse } from '@ngrx/operators';
-import { Receipt } from '../models';
+import { computed, effect, inject, resource, untracked } from '@angular/core';
+import {
+ CanReturn,
+ ProductCategory,
+ Receipt,
+ ReceiptItem,
+ ReturnDetailsService,
+} from '@isa/oms/data-access';
+import {
+ getState,
+ patchState,
+ signalStore,
+ type,
+ withComputed,
+ withMethods,
+ withProps,
+ withState,
+} from '@ngrx/signals';
+import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities';
+import {
+ canReturnReceiptItem,
+ getReceiptItemProductCategory,
+ receiptItemHasCategory,
+} from '../helpers/return-process';
+import { SessionStorageProvider } from '@isa/core/storage';
+import { logger } from '@isa/core/logging';
-/**
- * Represents the result of a return operation, including the receipt data and status.
- */
-export type ReturnResult = AsyncResult & { id: number };
+interface ReturnDetailsState {
+ _storageId: number | undefined;
+ _selectedItemIds: number[];
+ selectedProductCategory: Record;
+ selectedQuantity: Record;
+ canReturn: Record;
+}
-/**
- * Initial state for a return result entity, excluding the unique identifier.
- */
-const initialEntity: Omit = {
- data: undefined,
- status: AsyncResultStatus.Idle,
+const initialState: ReturnDetailsState = {
+ _storageId: undefined,
+ _selectedItemIds: [],
+ selectedProductCategory: {},
+ selectedQuantity: {},
+ canReturn: {},
};
-/**
- * Store for managing return details using NgRx signals.
- * Provides methods for fetching and updating return details.
- */
+export const receiptConfig = entityConfig({
+ entity: type(),
+ collection: 'receipts',
+});
+
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
- withEntities(),
+ withState(initialState),
+ withEntities(receiptConfig),
+ withProps(() => ({
+ _logger: logger(() => ({ store: 'ReturnDetailsStore' })),
+ _returnDetailsService: inject(ReturnDetailsService),
+ _storage: inject(SessionStorageProvider),
+ })),
withMethods((store) => ({
- /**
- * Prepares the store before fetching return details by adding or updating the entity with a pending status.
- * @param receiptId - The unique identifier of the receipt.
- * @returns The updated or newly created entity.
- */
- beforeFetch(receiptId: number) {
- // Using optional chaining to safely retrieve the entity from the map
- let entity: ReturnResult | undefined = store.entityMap()?.[receiptId];
- if (!entity) {
- entity = {
- ...initialEntity,
- id: receiptId,
- status: AsyncResultStatus.Pending,
- };
- patchState(store, addEntity(entity));
+ _storageKey: () => `ReturnDetailsStore:${store._storageId}`,
+ })),
+ withMethods((store) => ({
+ _storeState: () => {
+ const state = getState(store);
+ if (!store._storageId) {
+ return;
+ }
+ store._storage.set(store._storageKey(), state);
+ store._logger.debug('State stored:', () => state);
+ },
+ _restoreState: async () => {
+ const data = await store._storage.get(store._storageKey());
+ if (data) {
+ patchState(store, data);
+ store._logger.debug('State restored:', () => ({ data }));
} else {
- patchState(
- store,
- updateEntity({
- id: receiptId,
- changes: { status: AsyncResultStatus.Pending },
- }),
- );
+ patchState(store, { ...initialState, _storageId: store._storageId() });
+ store._logger.debug('No state found, initialized with default state');
}
},
-
- /**
- * Updates the store with the fetched return details on a successful fetch operation.
- * @param receiptId - The unique identifier of the receipt.
- * @param data - The fetched receipt data.
- */
- fetchSuccess(receiptId: number, data: Receipt) {
- patchState(
- store,
- updateEntity({
- id: receiptId,
- changes: { data, status: AsyncResultStatus.Success },
- }),
- );
- },
-
- /**
- * Updates the store with an error state if the fetch operation fails.
- * @param receiptId - The unique identifier of the receipt.
- * @param error - The error encountered during the fetch operation.
- */
- fetchError(receiptId: number, error: unknown) {
- patchState(
- store,
- updateEntity({
- id: receiptId,
- changes: { error, status: AsyncResultStatus.Error },
- }),
- );
- },
-
- /**
- * Updates the product quantity for a specific item in a receipt.
- * This method modifies the quantity feature of an item within a receipt's data.
- *
- * @param params - Object containing parameters for the update
- * @param params.receiptId - The unique identifier of the receipt containing the item
- * @param params.itemId - The unique identifier of the item to update
- * @param params.quantity - The new quantity value to assign to the item (defaults to 0 if falsy)
- * @returns void
- */
- updateProductQuantityForItem({
- receiptId,
- itemId,
- quantity,
- }: {
- receiptId: number;
- itemId: number;
- quantity: number;
- }) {
- const receipt = store.entityMap()?.[receiptId];
- if (!receipt) return;
-
- const updatedItems = receipt?.data?.items.map((item) => {
- if (item.id !== itemId) {
- return item;
- }
-
- return {
- ...item,
- data: {
- ...item.data,
- quantity: {
- ...item?.data?.quantity,
- quantity: quantity || 0,
- },
- },
- };
- });
-
- patchState(
- store,
- updateEntity({
- id: receiptId,
- changes: {
- data: {
- ...receipt.data,
- items: updatedItems,
- } as Receipt,
- },
- }),
- );
- },
-
- /**
- * Updates the product category for a specific item in a receipt.
- * This method modifies the category feature of an item within a receipt's data.
- *
- * @param params - Object containing parameters for the update
- * @param params.receiptId - The unique identifier of the receipt containing the item
- * @param params.itemId - The unique identifier of the item to update
- * @param params.category - The new category value to assign to the item (defaults to 'unknown' if falsy)
- * @returns void
- */
- updateProductCategoryForItem({
- receiptId,
- itemId,
- category,
- }: {
- receiptId: number;
- itemId: number;
- category: string;
- }) {
- const receipt = store.entityMap()?.[receiptId];
- if (!receipt) return;
-
- const updatedItems = receipt?.data?.items.map((item) => {
- if (item.id !== itemId) {
- return item;
- }
-
- return {
- ...item,
- data: {
- ...item.data,
- features: {
- ...item?.data?.features,
- category: category || 'unknown',
- },
- },
- };
- });
-
- patchState(
- store,
- updateEntity({
- id: receiptId,
- changes: {
- data: {
- ...receipt.data,
- items: updatedItems,
- } as Receipt,
- },
- }),
- );
- },
})),
- withMethods((store, returnDetailsService = inject(ReturnDetailsService)) => ({
- /**
- * Fetches return details for a given receipt ID.
- * Updates the store with the appropriate state based on the fetch result.
- * @param params - An object containing the receipt ID.
- */
- fetch: rxMethod<{ receiptId: number }>(
- pipe(
- tap(({ receiptId }) => store.beforeFetch(receiptId)),
- switchMap(({ receiptId }) =>
- returnDetailsService.fetchReturnDetails({ receiptId }).pipe(
- tapResponse({
- next(value) {
- store.fetchSuccess(receiptId, value);
- },
- error(error) {
- store.fetchError(receiptId, error);
- },
- }),
- ),
- ),
- ),
+ withComputed((store) => ({
+ items: computed>(() =>
+ store
+ .receiptsEntities()
+ .map((receipt) => receipt.items)
+ .flat()
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ .map((container) => container.data!),
),
})),
+ withComputed((store) => ({
+ selectedItemIds: computed(() => {
+ const selectedIds = store._selectedItemIds();
+ const canReturn = store.canReturn();
+
+ return selectedIds.filter((id) => {
+ const canReturnResult = canReturn[id]?.result;
+ return typeof canReturnResult === 'boolean' ? canReturnResult : true;
+ });
+ }),
+ })),
+ withComputed((store) => ({
+ returnableItems: computed(() => {
+ const items = store.items();
+ return items.filter(canReturnReceiptItem);
+ }),
+ selectableItems: computed(() => {
+ const items = store.items();
+ const selectedProductCategory = store.selectedProductCategory();
+ return items.filter(canReturnReceiptItem).filter((item) => {
+ const category = selectedProductCategory[item.id];
+ return (
+ category || !receiptItemHasCategory(item, ProductCategory.Unknown)
+ );
+ });
+ }),
+ selectedItems: computed(() => {
+ const selectedIds = store.selectedItemIds();
+ const items = store
+ .receiptsEntities()
+ .flatMap((receipt) => receipt.items.map((item) => item.data!));
+ return items.filter((item) => selectedIds.includes(item.id));
+ }),
+ })),
+ withMethods((store) => ({
+ receiptResource: (receiptId: () => number | undefined) =>
+ resource({
+ request: receiptId,
+ loader: async ({ abortSignal, request }) => {
+ if (!request) {
+ return undefined;
+ }
+ const receipt = await store._returnDetailsService.fetchReturnDetails(
+ { receiptId: request },
+ abortSignal,
+ );
+ patchState(store, setEntity(receipt, receiptConfig));
+ store._storeState();
+ return receipt;
+ },
+ }),
+
+ canReturnResource: (receiptItem: () => ReceiptItem | undefined) =>
+ resource({
+ request: () => {
+ const item = receiptItem();
+
+ if (!item) {
+ return undefined;
+ }
+
+ return {
+ item: item,
+ category:
+ store.selectedProductCategory()[item.id] ||
+ getReceiptItemProductCategory(item),
+ };
+ },
+ loader: async ({ request, abortSignal }) => {
+ if (request === undefined) {
+ return undefined;
+ }
+ const key = `${request.item.id}:${request.category}`;
+
+ if (store.canReturn()[key]) {
+ return store.canReturn()[key];
+ }
+
+ const res = await store._returnDetailsService.canReturn(
+ request,
+ abortSignal,
+ );
+ patchState(store, {
+ canReturn: { ...store.canReturn(), [key]: res },
+ });
+
+ store._storeState();
+ return res;
+ },
+ }),
+ getReceipt: (receiptId: () => number) =>
+ computed(() => {
+ const id = receiptId();
+ const entities = store.receiptsEntityMap();
+ return entities[id];
+ }),
+ getItems: (receiptId: () => number) =>
+ computed(() => {
+ const id = receiptId();
+ const entities = store.receiptsEntityMap();
+ const receipt = entities[id];
+ return receipt?.items.map((item) => item.data!);
+ }),
+ getSelectableItems: (receiptId: () => number) =>
+ computed(() =>
+ store
+ .selectableItems()
+ .filter((item) => item.receipt?.id === receiptId()),
+ ),
+ getItemSelected: (item: () => ReceiptItem) =>
+ computed(() => {
+ const selectedIds = store.selectedItemIds();
+ return selectedIds.includes(item().id);
+ }),
+ getItemQuantity: (item: () => ReceiptItem) =>
+ computed(() => {
+ const itemData = item();
+ return (
+ store.selectedQuantity()[itemData.id] || itemData.quantity.quantity
+ );
+ }),
+ getProductCategory: (item: () => ReceiptItem) =>
+ computed(() => {
+ const itemData = item();
+ return (
+ store.selectedProductCategory()[itemData.id] ||
+ getReceiptItemProductCategory(itemData)
+ );
+ }),
+ isSelectable: (receiptItem: () => ReceiptItem) =>
+ computed(() => {
+ const item = receiptItem();
+ const selectableItems = store.selectableItems();
+ return selectableItems.some((i) => i.id === item.id);
+ }),
+ getCanReturn: (item: () => ReceiptItem) =>
+ computed(() => {
+ const itemData = item();
+ return store.canReturn()[itemData.id];
+ }),
+ })),
+
+ withMethods((store) => ({
+ selectStorage: (id: number) => {
+ untracked(() => {
+ patchState(store, { _storageId: id });
+ store._restoreState();
+ store._storeState();
+ store._logger.debug('Storage ID set:', () => ({ id }));
+ });
+ },
+ addSelectedItems(itemIds: number[]) {
+ const currentIds = store.selectedItemIds();
+ const newIds = Array.from(new Set([...currentIds, ...itemIds]));
+ patchState(store, { _selectedItemIds: newIds });
+ store._storeState();
+ },
+ removeSelectedItems(itemIds: number[]) {
+ const currentIds = store.selectedItemIds();
+ const newIds = currentIds.filter((id) => !itemIds.includes(id));
+ patchState(store, { _selectedItemIds: newIds });
+ store._storeState();
+ },
+ async setProductCategory(itemId: number, category: ProductCategory) {
+ const currentCategory = store.selectedProductCategory();
+ const newCategory = { ...currentCategory, [itemId]: category };
+ patchState(store, { selectedProductCategory: newCategory });
+ store._storeState();
+ },
+ setQuantity(itemId: number, quantity: number) {
+ const currentQuantity = store.selectedQuantity();
+ const newQuantity = { ...currentQuantity, [itemId]: quantity };
+ patchState(store, { selectedQuantity: newQuantity });
+ store._storeState();
+ },
+ })),
);
diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts
index 7b6defb89..586ae6383 100644
--- a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts
+++ b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts
@@ -3,7 +3,8 @@ import { ReturnProcessStore } from './return-process.store';
import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
-import { setAllEntities } from '@ngrx/signals/entities';
+import { setAllEntities, setEntity } from '@ngrx/signals/entities';
+import { unprotected } from '@ngrx/signals/testing';
import { Product, Receipt, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
@@ -17,6 +18,7 @@ const TEST_ITEMS: Record = {
formatDetail: 'Taschenbuch',
} as Product,
quantity: { quantity: 1 },
+ receiptNumber: 'R-001',
},
2: {
id: 2,
@@ -27,6 +29,7 @@ const TEST_ITEMS: Record = {
formatDetail: 'Buch',
} as Product,
quantity: { quantity: 1 },
+ receiptNumber: 'R-002',
},
3: {
id: 3,
@@ -37,6 +40,7 @@ const TEST_ITEMS: Record = {
formatDetail: 'Audio',
} as Product,
quantity: { quantity: 1 },
+ receiptNumber: 'R-003',
},
};
@@ -64,12 +68,39 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
- store as any,
+ unprotected(store),
setAllEntities([
- { id: 1, processId: 1, name: 'Process 1' },
- { id: 2, processId: 2, name: 'Process 2' },
- { id: 3, processId: 1, name: 'Process 3' },
- ]),
+ {
+ id: 1,
+ processId: 1,
+ receiptId: 1,
+ receiptItem: TEST_ITEMS[1],
+ receiptDate: '',
+ answers: {},
+ productCategory: undefined,
+ returnReceipt: undefined,
+ },
+ {
+ id: 2,
+ processId: 2,
+ receiptId: 2,
+ receiptItem: TEST_ITEMS[2],
+ receiptDate: '',
+ answers: {},
+ productCategory: undefined,
+ returnReceipt: undefined,
+ },
+ {
+ id: 3,
+ processId: 1,
+ receiptId: 3,
+ receiptItem: TEST_ITEMS[3],
+ receiptDate: '',
+ answers: {},
+ productCategory: undefined,
+ returnReceipt: undefined,
+ },
+ ] as ReturnProcess[]),
);
store.removeAllEntitiesByProcessId(1);
@@ -82,8 +113,19 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
- store as any,
- setAllEntities([{ id: 1, processId: 1, answers: {} }]),
+ unprotected(store),
+ setAllEntities([
+ {
+ id: 1,
+ processId: 1,
+ receiptId: 1,
+ receiptItem: TEST_ITEMS[1],
+ receiptDate: '',
+ answers: {},
+ productCategory: undefined,
+ returnReceipt: undefined,
+ },
+ ] as ReturnProcess[]),
);
store.setAnswer(1, 'question1', 'answer1');
@@ -95,10 +137,20 @@ describe('ReturnProcessStore', () => {
const store = spectator.service;
patchState(
- store as any,
- setAllEntities([
- { id: 1, processId: 1, answers: { question1: 'answer1' } },
- ]),
+ unprotected(store),
+ setEntity({
+ id: 1,
+ processId: 1,
+ answers: { question1: 'answer1', question2: 'answer2' } as Record<
+ string,
+ unknown
+ >,
+ receiptDate: new Date().toJSON(),
+ receiptItem: TEST_ITEMS[1],
+ receiptId: 123,
+ productCategory: undefined,
+ returnReceipt: undefined,
+ } as ReturnProcess),
);
store.removeAnswer(1, 'question1');
@@ -113,8 +165,26 @@ describe('ReturnProcessStore', () => {
store.startProcess({
processId: 1,
- receipt: {} as Receipt,
- items: [TEST_ITEMS[1], TEST_ITEMS[3]],
+ returns: [
+ {
+ receipt: {
+ id: 1,
+ printedDate: '',
+ items: [],
+ buyer: { buyerNumber: '' },
+ },
+ items: [TEST_ITEMS[1]],
+ },
+ {
+ receipt: {
+ id: 2,
+ printedDate: '',
+ items: [],
+ buyer: { buyerNumber: '' },
+ },
+ items: [TEST_ITEMS[3]],
+ },
+ ],
});
expect(store.entities()).toHaveLength(2);
@@ -127,8 +197,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
- receipt: {} as Receipt,
- items: [TEST_ITEMS[2]],
+ returns: [
+ {
+ receipt: {
+ id: 2,
+ printedDate: '',
+ items: [],
+ buyer: { buyerNumber: '' },
+ },
+ items: [TEST_ITEMS[2]], // Non-returnable item
+ },
+ ],
});
}).toThrow(CreateReturnProcessError);
});
@@ -140,8 +219,17 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
- receipt: {} as Receipt,
- items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
+ returns: [
+ {
+ receipt: {
+ id: 3,
+ printedDate: '',
+ items: [],
+ buyer: { buyerNumber: '' },
+ },
+ items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
+ },
+ ],
});
}).toThrow(CreateReturnProcessError);
});
diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.ts b/libs/oms/data-access/src/lib/stores/return-process.store.ts
index b9f9e4ff4..ef4444e0c 100644
--- a/libs/oms/data-access/src/lib/stores/return-process.store.ts
+++ b/libs/oms/data-access/src/lib/stores/return-process.store.ts
@@ -4,6 +4,7 @@ import {
withComputed,
withHooks,
withMethods,
+ withProps,
} from '@ngrx/signals';
import {
withEntities,
@@ -14,19 +15,19 @@ import { IDBStorageProvider, withStorage } from '@isa/core/storage';
import { computed, effect, inject } from '@angular/core';
import { ProcessService } from '@isa/core/process';
import { Receipt, ReceiptItem, ReturnProcess } from '../models';
-import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from '../errors/return-process';
+import { logger } from '@isa/core/logging';
+import { canReturnReceiptItem } from '../helpers/return-process';
/**
* Interface representing the parameters required to start a return process.
*/
export type StartProcess = {
processId: number;
- receipt: Receipt;
- items: ReceiptItem[];
+ returns: { receipt: Receipt; items: ReceiptItem[] }[];
};
/**
@@ -57,6 +58,11 @@ export const ReturnProcessStore = signalStore(
{ providedIn: 'root' },
withStorage('return-process', IDBStorageProvider),
withEntities(),
+ withProps(() => ({
+ _logger: logger(() => ({
+ store: 'ReturnProcessStore',
+ })),
+ })),
withComputed((store) => ({
nextId: computed(() => Math.max(0, ...store.ids().map(Number)) + 1),
})),
@@ -134,38 +140,40 @@ export const ReturnProcessStore = signalStore(
const entities: ReturnProcess[] = [];
const nextId = store.nextId();
- const returnableItems = params.items.filter((item) =>
- item.actions?.some(
- (a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
- ),
- );
+ const returnableItems = params.returns
+ .flatMap((r) => r.items)
+ .filter(canReturnReceiptItem);
if (returnableItems.length === 0) {
- throw new CreateReturnProcessError(
+ const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS,
params,
);
+ store._logger.error(err.message, err);
+ throw err;
}
- if (returnableItems.length !== params.items.length) {
- throw new CreateReturnProcessError(
+ if (returnableItems.length !== params.returns.length) {
+ const err = new CreateReturnProcessError(
CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS,
params,
);
+ store._logger.error(err.message, err);
+ throw err;
}
- for (let i = 0; i < params.items.length; i++) {
- const item = params.items[i];
-
- entities.push({
- id: nextId + i,
- processId: params.processId,
- receiptId: params.receipt.id,
- productCategory: item.features?.['category'],
- receiptDate: params.receipt.printedDate,
- receiptItem: item,
- answers: {},
- });
+ for (const { receipt, items } of params.returns) {
+ for (const item of items) {
+ entities.push({
+ id: nextId + entities.length,
+ processId: params.processId,
+ receiptId: receipt.id,
+ productCategory: item.features?.['category'],
+ receiptDate: receipt.printedDate,
+ receiptItem: item,
+ answers: {},
+ });
+ }
}
patchState(store, setAllEntities(entities));
diff --git a/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts
index 48cad5888..87611aaf8 100644
--- a/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts
+++ b/libs/oms/data-access/src/lib/stores/return-search.store.spec.ts
@@ -75,7 +75,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
- result: [{ id: 1 }],
+ result: [{ id: 1 } as ReceiptListItem],
};
spectator.service.beforeSearch(1);
@@ -111,7 +111,7 @@ describe('ReturnSearchStore', () => {
error: false,
take: 10,
invalidProperties: {},
- result: [{ id: 1 }],
+ result: [{ id: 1 } as ReceiptListItem],
};
const returnSearchService = spectator.inject(ReturnSearchService);
returnSearchService.search.mockReturnValue(of(mockResponse));
diff --git a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html
index 7faa754cb..98b674489 100644
--- a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html
+++ b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.html
@@ -1,3 +1,5 @@
+@let r = receipt();
+
Belegdatum:
@@ -10,7 +12,7 @@
Belegart:
- {{ receipt().receiptType | omsReceiptTypeTranslation }}
+ {{ r.receiptType | omsReceiptTypeTranslation }}
diff --git a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts
index bfa4a571a..f2e0853a4 100644
--- a/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts
+++ b/libs/oms/feature/return-details/src/lib/return-details-data/return-details-data.component.ts
@@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { Receipt } from '@isa/oms/data-access';
import { ReceiptTypeTranslationPipe } from '@isa/oms/utils/translation';
import { ItemRowDataImports } from '@isa/ui/item-rows';
+
+export type ReceiptInput = Pick;
+
@Component({
selector: 'oms-feature-return-details-data',
templateUrl: './return-details-data.component.html',
@@ -11,5 +14,5 @@ import { ItemRowDataImports } from '@isa/ui/item-rows';
imports: [ItemRowDataImports, ReceiptTypeTranslationPipe, DatePipe],
})
export class ReturnDetailsDataComponent {
- receipt = input.required();
+ receipt = input.required();
}
diff --git a/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts b/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts
index 5cad3d346..a047e85ec 100644
--- a/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts
+++ b/libs/oms/feature/return-details/src/lib/return-details-header/return-details-header.component.ts
@@ -6,13 +6,13 @@ import {
input,
} from '@angular/core';
import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons';
-import { Buyer } from '@isa/oms/data-access';
import { InfoButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { formatName } from 'libs/oms/utils/format-name';
import { UiMenu } from '@isa/ui/menu';
import { RouterLink } from '@angular/router';
import { CdkMenuTrigger } from '@angular/cdk/menu';
+import { ReturnDetailsStore } from '@isa/oms/data-access';
@Component({
selector: 'oms-feature-return-details-header',
@@ -30,7 +30,13 @@ import { CdkMenuTrigger } from '@angular/cdk/menu';
providers: [provideIcons({ isaNavigationKunden, isaActionChevronDown })],
})
export class ReturnDetailsHeaderComponent {
- buyer = input.required();
+ #store = inject(ReturnDetailsStore);
+
+ receiptId = input.required();
+
+ receipt = this.#store.getReceipt(this.receiptId);
+
+ buyer = computed(() => this.receipt().buyer);
referenceId = computed(() => {
const buyer = this.buyer();
@@ -38,7 +44,11 @@ export class ReturnDetailsHeaderComponent {
});
name = computed(() => {
- console.log({ buyer: this.buyer() });
+ const buyer = this.buyer();
+ if (!buyer) {
+ return '';
+ }
+
const firstName = this.buyer()?.firstName;
const lastName = this.buyer()?.lastName;
const organisationName = this.buyer()?.organisation?.name;
diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html
new file mode 100644
index 000000000..6ce7b6689
--- /dev/null
+++ b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.html
@@ -0,0 +1,43 @@
+
+
+
+
+ @if (receiptResource.isLoading()) {
+
+ } @else if (receiptResource.value()) {
+ @let r = receiptResource.value()!;
+
+
+
+
+
+ @for (item of r.items; track item.id; let last = $last) {
+
+ }
+ }
+
+
diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.scss b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts
new file mode 100644
index 000000000..99b2c1abc
--- /dev/null
+++ b/libs/oms/feature/return-details/src/lib/return-details-lazy/return-details-lazy.component.ts
@@ -0,0 +1,57 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ viewChild,
+} from '@angular/core';
+import { ReturnDetailsOrderGroupComponent } from '../return-details-order-group/return-details-order-group.component';
+import { ReturnDetailsOrderGroupDataComponent } from '../return-details-order-group-data/return-details-order-group-data.component';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { isaActionPlus, isaActionMinus } from '@isa/icons';
+import { ReturnDetailsOrderGroupItemComponent } from '../return-details-order-group-item/return-details-order-group-item.component';
+import { ReceiptListItem, ReturnDetailsStore } from '@isa/oms/data-access';
+import { ExpandableDirective, ExpandableDirectives } from '@isa/ui/expandable';
+import { TextButtonComponent } from '@isa/ui/buttons';
+import { ProgressBarComponent } from '@isa/ui/progress-bar';
+
+@Component({
+ selector: 'oms-feature-return-details-lazy',
+ templateUrl: './return-details-lazy.component.html',
+ styleUrls: ['./return-details-lazy.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ imports: [
+ ReturnDetailsOrderGroupComponent,
+ ReturnDetailsOrderGroupDataComponent,
+ ReturnDetailsOrderGroupItemComponent,
+ NgIcon,
+ ExpandableDirectives,
+ TextButtonComponent,
+ ProgressBarComponent,
+ ],
+ providers: [provideIcons({ isaActionPlus, isaActionMinus })],
+})
+export class ReturnDetailsLazyComponent {
+ #store = inject(ReturnDetailsStore);
+ receipt = input.required();
+
+ receiptMap = this.#store.receiptsEntityMap;
+
+ exbandable = viewChild(ExpandableDirective);
+
+ receiptId = computed(() => {
+ const ex = this.exbandable();
+ if (!ex) {
+ return;
+ }
+
+ if (!ex.expanded()) {
+ return;
+ }
+ return this.receipt().id;
+ });
+
+ receiptResource = this.#store.receiptResource(this.receiptId);
+}
diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html
index f3dbe6308..63bd2051c 100644
--- a/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html
+++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-data/return-details-order-group-data.component.html
@@ -1,49 +1,51 @@
-
-
- Belegdatum:
-
- {{ (receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
-
-
-
- Belegart:
-
- {{ receipt().receiptType | omsReceiptTypeTranslation }}
-
-
-
- Vorgang-ID:
-
- {{ receipt().order?.data?.orderNumber }}
-
-
-
- Bestelldatum:
-
- {{
- (receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr'
- }}
-
-
- @if (receipt().buyer?.address; as address) {
-
- Anschrift:
-
-