Merge branch 'develop' into release/4.0

This commit is contained in:
Lorenz Hilpert
2025-06-02 11:41:54 +02:00
173 changed files with 7222 additions and 3772 deletions

32
.github/instructions/nx.instructions.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 20.4.6 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- 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

3
.gitignore vendored
View File

@@ -62,3 +62,6 @@ libs/swagger/src/lib/*
storybook-static
.cursor\rules\nx-rules.mdc
.github\instructions\nx.instructions.md

15
.vscode/settings.json vendored
View File

@@ -30,15 +30,6 @@
}
],
"github.copilot.chat.codeGeneration.instructions": [
{
"file": ".github/copilot-instructions.md"
},
{
"file": ".github/review-instructions.md"
},
{
"file": ".github/testing-instructions.md"
},
{
"file": "docs/tech-stack.md"
},
@@ -56,9 +47,6 @@
}
],
"github.copilot.chat.testGeneration.instructions": [
{
"file": ".github/copilot-instructions.md"
},
{
"file": ".github/testing-instructions.md"
},
@@ -95,4 +83,7 @@
"file": "docs/guidelines/testing.md"
}
],
"nxConsole.generateAiAgentRules": true,
"chat.mcp.enabled": true,
"chat.mcp.discovery.enabled": true
}

View File

@@ -1,5 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { PromptModalData, UiModalService, UiPromptModalComponent } from '@ui/modal';
import {
PromptModalData,
UiModalService,
UiPromptModalComponent,
} from '@ui/modal';
import { Observable } from 'rxjs';
import { ScanAdapter } from './scan-adapter';
import { Config } from '@core/config';
@@ -14,9 +18,14 @@ export class DevScanAdapter implements ScanAdapter {
private _config = inject(Config);
async init(): Promise<boolean> {
return new Promise((resolve, reject) => {
resolve(coerceBooleanProperty(this._config.get('dev-scanner')));
});
const enabled = localStorage.getItem('dev_scan_adapter_enabled') === 'true';
if (enabled) {
return new Promise((resolve, reject) => {
resolve(coerceBooleanProperty(this._config.get('dev-scanner')));
});
}
return false;
}
scan(): Observable<string> {
@@ -25,7 +34,8 @@ export class DevScanAdapter implements ScanAdapter {
content: UiPromptModalComponent,
title: 'Scannen',
data: {
message: 'Diese Eingabemaske dient nur zu Entwicklungs und Testzwecken.',
message:
'Diese Eingabemaske dient nur zu Entwicklungs und Testzwecken.',
placeholder: 'Scan Code',
confirmText: 'weiter',
cancelText: 'abbrechen',

View File

@@ -27,7 +27,10 @@ export class ScanditScanAdapter implements ScanAdapter {
) {}
async init(): Promise<boolean> {
if (this._environmentService.isTablet()) {
const enabled =
localStorage.getItem('scandit_scan_adapter_enabled') === 'true';
if (enabled || this._environmentService.isTablet()) {
try {
await configure({
licenseKey: this._config.get('licence.scandit'),
@@ -88,7 +91,11 @@ export class ScanditScanAdapter implements ScanAdapter {
createOverlay() {
const overlay = this._overlay.create({
positionStrategy: this._overlay.position().global().centerHorizontally().centerVertically(),
positionStrategy: this._overlay
.position()
.global()
.centerHorizontally()
.centerVertically(),
hasBackdrop: true,
});

View File

@@ -20,7 +20,7 @@
"olaExpiration": "5m"
},
"@swagger/isa": {
"rootUrl": "https://isa-test.paragon-data.net/isa/v1"
"rootUrl": "https://isa-feature.paragon-data.net/isa/v1"
},
"@swagger/cat": {
"rootUrl": "https://isa-test.paragon-data.net/catsearch/v6"
@@ -38,20 +38,20 @@
"rootUrl": "https://isa-feature.paragon-data.net/oms/v6"
},
"@swagger/print": {
"rootUrl": "https://isa-test.paragon-data.net/print/v1"
"rootUrl": "https://isa-feature.paragon-data.net/print/v1"
},
"@swagger/eis": {
"rootUrl": "https://filialinformationsystem-test.paragon-systems.de/eiswebapi/v1"
},
"@swagger/remi": {
"rootUrl": "https://isa-test.paragon-data.net/inv/v6"
"rootUrl": "https://isa-feature.paragon-data.net/inv/v6"
},
"@swagger/wws": {
"rootUrl": "https://isa-test.paragon-data.net/wws/v1"
},
"hubs": {
"notifications": {
"url": "https://isa-test.paragon-data.net/isa/v1/rt",
"url": "https://isa-feature.paragon-data.net/isa/v1/rt",
"enableAutomaticReconnect": false,
"httpOptions": {
"transport": 1,

View File

@@ -3,11 +3,11 @@ import {
ChangeDetectionStrategy,
Input,
ContentChild,
ElementRef,
ViewEncapsulation,
ChangeDetectorRef,
OnDestroy,
AfterContentInit,
inject,
} from '@angular/core';
import { NgControl } from '@angular/forms';
import { EmptyControl } from './empty-control';
@@ -22,7 +22,7 @@ import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
styleUrls: ['form-control.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { class: 'shared-form-control' },
host: { 'class': 'shared-form-control', '[attr.label]': 'label' },
imports: [FirstErrorPipe],
})
export class FormControlComponent implements OnDestroy, AfterContentInit {
@@ -32,6 +32,8 @@ export class FormControlComponent implements OnDestroy, AfterContentInit {
@ContentChild(NgControl, { static: true })
controlDirective: NgControl;
private _cdr = inject(ChangeDetectorRef);
get control() {
return this.controlDirective.control;
}
@@ -39,7 +41,10 @@ export class FormControlComponent implements OnDestroy, AfterContentInit {
required = false;
get displayLabel() {
return (this.label ?? this.control?.['name'] ?? '') + (this.required && this._hasRequiredMark ? '*' : '');
return (
(this.label ?? this.control?.['name'] ?? '') +
(this.required && this._hasRequiredMark ? '*' : '')
);
}
private _onDestroy$ = new Subject<void>();
@@ -50,17 +55,14 @@ export class FormControlComponent implements OnDestroy, AfterContentInit {
this._hasRequiredMark = coerceBooleanProperty(value);
}
constructor(
private _elementRef: ElementRef,
private _cdr: ChangeDetectorRef,
) {}
ngAfterContentInit() {
this.checkValidator();
this.control.statusChanges.pipe(takeUntil(this._onDestroy$)).subscribe(() => {
this.checkValidator();
this._cdr.markForCheck();
});
this.control.statusChanges
.pipe(takeUntil(this._onDestroy$))
.subscribe(() => {
this.checkValidator();
this._cdr.markForCheck();
});
}
ngOnDestroy() {
@@ -73,6 +75,4 @@ export class FormControlComponent implements OnDestroy, AfterContentInit {
this.required = !!errors?.required;
}
clickLabel() {}
}

View File

@@ -1,4 +1,6 @@
@use '../../../libs/ui/buttons/src/buttons.scss';
@use '../../../libs/ui/datepicker/src/datepicker.scss';
@use '../../../libs/ui/input-controls/src/input-controls.scss';
@use '../../../libs/ui/progress-bar/src/lib/progress-bar.scss';
@use "../../../libs/ui/buttons/src/buttons.scss";
@use "../../../libs/ui/datepicker/src/datepicker.scss";
@use "../../../libs/ui/dialog/src/dialog.scss";
@use "../../../libs/ui/input-controls/src/input-controls.scss";
@use "../../../libs/ui/menu/src/menu.scss";
@use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss";

View File

@@ -0,0 +1,7 @@
# common-print
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test common-print` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'commonPrint',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'common-print',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'common-print',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/common/print',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "common-print",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/common/print/src",
"prefix": "common-print",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/common/print/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/models';
export * from './lib/services';

View File

@@ -0,0 +1,2 @@
export * from './printer.model';
export * from './printer-type.enum';

View File

@@ -0,0 +1,15 @@
/**
* Enum representing the different types of printers available in the system
*/
export const PrinterType = {
/** Standard office printers for documents */
OFFICE: 'office',
/** Specialized label printers */
LABEL: 'label',
} as const;
/**
* Type representing the possible printer types
* Used for type safety when specifying printer types
*/
export type PrinterType = (typeof PrinterType)[keyof typeof PrinterType];

View File

@@ -0,0 +1,16 @@
import { KeyValueDTOOfStringAndString } from '@generated/swagger/print-api';
/**
* Represents a printer in the system
* Extends the basic KeyValueDTO with additional printer-specific properties
*/
export interface Printer extends KeyValueDTOOfStringAndString {
/** Unique identifier for the printer */
key: string;
/** Display name of the printer */
value: string;
/** Whether this printer is currently selected as default */
selected: boolean;
/** Whether this printer is currently enabled for use */
enabled: boolean;
}

View File

@@ -0,0 +1,34 @@
<p>
Bitte wählen Sie aus der Liste den Drucker aus, mit dem Sie drucken möchten.
</p>
@if (error()) {
<div>
<p class="text-isa-accent-red">{{ formatError(error()) }}</p>
</div>
}
<div
uiListbox
[value]="selected()"
(valueChange)="select($event.value[0])"
[compareWith]="compareWith"
[disabled]="printing()"
class="max-h-96 overflow-y-auto"
>
@for (printer of data.printers; track printer.key) {
<button uiListboxItem [value]="printer">{{ printer.value }}</button>
}
</div>
<div class="grid grid-cols-2 gap-2">
<button uiButton color="secondary" (click)="close({ printer: undefined })">
Verlassen
</button>
<button
uiButton
color="primary"
(click)="print()"
[disabled]="!canPrint()"
[pending]="printing()"
>
Drucken
</button>
</div>

View File

@@ -0,0 +1,290 @@
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<PrintDialogComponent>;
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 });
});
});

View File

@@ -0,0 +1,127 @@
import {
ChangeDetectionStrategy,
Component,
computed,
signal,
} from '@angular/core';
import { Printer } from '../models';
import { ButtonComponent } from '@isa/ui/buttons';
import { DialogContentDirective } from '@isa/ui/dialog';
import { ListboxDirective, ListboxItemDirective } from '@isa/ui/input-controls';
/**
* Input data for the printer dialog component
*/
export interface PrinterDialogData {
/** Array of available printers to display */
printers: Printer[];
/** Optional error to display in the dialog */
error?: unknown;
/** Function to call when user selects a printer and clicks print */
print: (printer: Printer) => Promise<unknown>;
}
/**
* Result returned when the printer dialog closes
*/
export interface PrinterDialogResult {
/** Selected printer or undefined if user cancelled */
printer: Printer | undefined;
}
/**
* Dialog component for printer selection and print operations
* Displays a list of printers, allows selection, and handles print errors
*/
@Component({
selector: 'common-print-dialog',
templateUrl: './print-dialog.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent, ListboxDirective, ListboxItemDirective],
})
export class PrintDialogComponent extends DialogContentDirective<
PrinterDialogData,
PrinterDialogResult
> {
/** Signal for the currently selected printer */
printer = signal<Printer | undefined>(
this.data.printers.find((p) => p.selected),
);
/** Computed array of selected printers for the listbox component */
selected = computed(() => {
const printer = this.printer();
if (printer) {
return [printer];
}
return [];
});
/** Signal indicating if a print operation is in progress */
printing = signal(false);
/** Signal for displaying error information */
error = signal<unknown>(this.data.error);
/** Computed property determining if the print button should be enabled */
canPrint = computed(() => {
return this.printer() !== undefined && !this.printing();
});
/**
* Comparison function for printer objects
* @param a First printer to compare
* @param b Second printer to compare
* @returns True if the printers have the same key
*/
compareWith(a: Printer, b: Printer) {
return a.key === b.key;
}
/**
* Handles printer selection in the list
* @param printer The printer that was selected
*/
select(printer: Printer) {
this.error.set(undefined);
this.printer.set(printer);
}
/**
* Executes the print operation with the selected printer
* Sets error state if the print operation fails
* Closes the dialog with the selected printer if successful
*/
async print() {
this.error.set(undefined);
const printer = this.printer();
if (!this.canPrint() || !printer) {
return;
}
this.printing.set(true);
try {
await this.data.print(printer);
} catch (error) {
this.error.set(error);
this.printing.set(false);
return;
}
this.close({ printer });
}
/**
* Formats error objects for display in the UI
* @param error The error object to format
* @returns A user-friendly error message string
*/
formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
} else if (typeof error === 'string') {
return error;
} else {
return 'Unbekannter Fehler';
}
}
}

View File

@@ -0,0 +1 @@
export * from './print.service';

View File

@@ -0,0 +1,279 @@
import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest';
import { PrintService } from './print.service';
import { Platform } from '@angular/cdk/platform';
import {
ListResponseArgsOfKeyValueDTOOfStringAndString,
PrintService as PrintApiService,
} from '@generated/swagger/print-api';
import { of } from 'rxjs';
import { PrinterType } from '../models';
import { PrinterDialogResult } from '../print-dialog/print-dialog.component';
// Mock dialog function that can be configured in tests
const mockDialogClosedObservable = {
closed: of<PrinterDialogResult | undefined>({ printer: undefined }),
};
const mockDialogFn = jest.fn().mockReturnValue(mockDialogClosedObservable);
jest.mock('@isa/ui/dialog', () => ({
injectDialog: jest.fn().mockImplementation(() => mockDialogFn),
}));
jest.mock('../print-dialog/print-dialog.component', () => ({
PrintDialogComponent: jest
.fn()
.mockReturnValue(class MockPrintDialogComponent {}),
}));
describe('PrintService', () => {
let spectator: SpectatorService<PrintService>;
const createService = createServiceFactory({
service: PrintService,
mocks: [PrintApiService, Platform],
});
beforeEach(() => {
spectator = createService();
});
it('should be created', () => {
expect(spectator.service).toBeTruthy();
});
describe('labelPrinters', () => {
it('should call PrintLabelPrinters and return printers', async () => {
const mockPrinters: ListResponseArgsOfKeyValueDTOOfStringAndString = {
error: false,
result: [
{
key: 'printer1',
value: 'Printer 1',
},
{
key: 'printer2',
value: 'Printer 2',
},
],
};
const printApiService: jest.Mocked<PrintApiService> =
spectator.inject(PrintApiService);
printApiService.PrintLabelPrinters.mockReturnValue(of(mockPrinters));
const printers = await spectator.service.labelPrinters();
expect(printApiService.PrintLabelPrinters).toHaveBeenCalled();
expect(printers).toEqual(mockPrinters.result);
});
});
describe('officePrinters', () => {
it('should call PrintOfficePrinters and return printers', async () => {
const mockPrinters: ListResponseArgsOfKeyValueDTOOfStringAndString = {
error: false,
result: [
{
key: 'printer1',
value: 'Printer 1',
},
{
key: 'printer2',
value: 'Printer 2',
},
],
};
const printApiService: jest.Mocked<PrintApiService> =
spectator.inject(PrintApiService);
printApiService.PrintOfficePrinters.mockReturnValue(of(mockPrinters));
const printers = await spectator.service.officePrinters();
expect(printApiService.PrintOfficePrinters).toHaveBeenCalled();
expect(printers).toEqual(mockPrinters.result);
});
});
describe('printers', () => {
it('should call labelPrinters when printerType is LABEL', async () => {
const labelPrintersSpy = jest
.spyOn(spectator.service, 'labelPrinters')
.mockReturnValue(Promise.resolve([]));
await spectator.service.printers(PrinterType.LABEL);
expect(labelPrintersSpy).toHaveBeenCalled();
});
it('should call officePrinters when printerType is OFFICE', async () => {
const officePrintersSpy = jest
.spyOn(spectator.service, 'officePrinters')
.mockReturnValue(Promise.resolve([]));
await spectator.service.printers(PrinterType.OFFICE);
expect(officePrintersSpy).toHaveBeenCalled();
});
});
describe('print', () => {
const mockPrinters = [
{ key: 'printer1', value: 'Printer 1', selected: false, enabled: true },
{ key: 'printer2', value: 'Printer 2', selected: true, enabled: true },
];
let mockPrint: jest.Mock;
let platform: jest.Mocked<Platform>;
let printSpy: jest.SpyInstance;
beforeEach(() => {
mockPrint = jest.fn().mockResolvedValue(undefined);
platform = spectator.inject(Platform);
// Mock the printers method to return our mock printers
printSpy = jest
.spyOn(spectator.service, 'printers')
.mockResolvedValue(mockPrinters);
// Reset platform flags before each test
platform.ANDROID = false;
platform.IOS = false;
// Reset the dialog mock before each test
mockDialogFn.mockClear();
mockDialogClosedObservable.closed = of({ printer: mockPrinters[1] });
});
it('should fetch printers of the specified type', async () => {
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(printSpy).toHaveBeenCalledWith(PrinterType.LABEL);
});
it('should attempt direct printing on desktop when a printer is selected', async () => {
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockPrint).toHaveBeenCalledWith(mockPrinters[1]);
expect(mockDialogFn).not.toHaveBeenCalled();
});
it('should return the selected printer after successful direct print', async () => {
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
// Assert
expect(result).toEqual({ printer: mockPrinters[1] });
});
it('should show dialog with error when direct printing fails', async () => {
// Arrange
const printError = new Error('Print failed');
mockPrint.mockRejectedValueOnce(printError);
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockDialogFn).toHaveBeenCalledWith({
data: {
printers: mockPrinters,
error: printError,
print: mockPrint,
},
});
});
it('should not attempt direct printing on Android', async () => {
// Arrange
platform.ANDROID = true;
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockPrint).not.toHaveBeenCalled();
expect(mockDialogFn).toHaveBeenCalled();
});
it('should not attempt direct printing on iOS', async () => {
// Arrange
platform.IOS = true;
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockPrint).not.toHaveBeenCalled();
expect(mockDialogFn).toHaveBeenCalled();
});
it('should show print dialog when no printer is selected', async () => {
// Arrange
const printersWithoutSelection = [
{ key: 'printer1', value: 'Printer 1', selected: false, enabled: true },
{ key: 'printer2', value: 'Printer 2', selected: false, enabled: true },
];
printSpy.mockResolvedValueOnce(printersWithoutSelection);
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockPrint).not.toHaveBeenCalled();
expect(mockDialogFn).toHaveBeenCalled();
});
it('should pass the correct data to the print dialog', async () => {
// Arrange
platform.ANDROID = true; // Force dialog to show
// Act
await spectator.service.print(PrinterType.LABEL, mockPrint);
// Assert
expect(mockDialogFn).toHaveBeenCalledWith({
data: {
printers: mockPrinters,
error: undefined,
print: mockPrint,
},
});
});
it('should return the printer selected in the dialog', async () => {
// Arrange
platform.ANDROID = true; // Force dialog to show
const selectedPrinter = mockPrinters[0];
mockDialogClosedObservable.closed = of({ printer: selectedPrinter });
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
// Assert
expect(result).toEqual({ printer: selectedPrinter });
});
it('should return undefined printer when dialog is cancelled', async () => {
// Arrange
platform.ANDROID = true; // Force dialog to show
mockDialogClosedObservable.closed = of(undefined);
// Act
const result = await spectator.service.print(
PrinterType.LABEL,
mockPrint,
);
// Assert
expect(result).toEqual({ printer: undefined });
});
});
});

View File

@@ -0,0 +1,100 @@
import { inject, Injectable } from '@angular/core';
import { PrintService as PrintApiService } from '@generated/swagger/print-api';
import { Platform } from '@angular/cdk/platform';
import { firstValueFrom, map } from 'rxjs';
import { Printer, PrinterType } from '../models';
import { injectDialog } from '@isa/ui/dialog';
import { PrintDialogComponent } from '../print-dialog/print-dialog.component';
/**
* Service that provides access to printers available in the system.
* Communicates with the print API to retrieve label and office printers.
*/
@Injectable({ providedIn: 'root' })
export class PrintService {
#printService = inject(PrintApiService);
#printDailog = injectDialog(PrintDialogComponent, 'Drucken');
#platform = inject(Platform);
/**
* Retrieves a list of available label printers
* @returns Observable of label printer array
*/
labelPrinters(): Promise<Printer[]> {
return firstValueFrom(
this.#printService
.PrintLabelPrinters()
.pipe(map((res) => res.result as Printer[])),
);
}
/**
* Retrieves a list of available office printers
* @returns Observable of office printer array
*/
officePrinters(): Promise<Printer[]> {
return firstValueFrom(
this.#printService
.PrintOfficePrinters()
.pipe(map((res) => res.result as Printer[])),
);
}
printers(printerType: PrinterType): Promise<Printer[]> {
return printerType === PrinterType.LABEL
? this.labelPrinters()
: this.officePrinters();
}
/**
* Initiates a print operation with platform-specific optimizations
* On desktop, attempts to print directly using the default printer if available
* Falls back to showing a printer selection dialog if needed or on mobile devices
*
* @param printerType The type of printer to use (LABEL or OFFICE)
* @param printFn Function that performs the actual print operation with the selected printer
* @returns Object containing the selected printer or undefined if operation was cancelled
*/
async print(
printerType: PrinterType,
printFn: (printer: Printer) => Promise<any>,
): Promise<{ printer?: Printer }> {
// Get the list of printers based on the printer type
const printers = await this.printers(printerType);
const selectedPrinter = printers.find((p) => p.selected);
// If the platform is not Android or iOS, we can assume this is stationary devices
// and we can try to print directly to the selected printer.
// If it fails, we show the print dialog with the error.
const directPrintAllowed =
selectedPrinter && !(this.#platform.ANDROID || this.#platform.IOS);
let error: unknown | undefined = undefined;
if (directPrintAllowed) {
try {
await printFn(selectedPrinter);
return { printer: selectedPrinter };
} catch (e) {
error = e;
}
}
// Default behavior: show the print dialog
// and let the user select a printer.
const result = await firstValueFrom(
this.#printDailog({
data: {
printers,
error,
print: printFn,
},
}).closed,
);
// Returns a printer if the print was successful
// or undefined if the user canceled the dialog.
return { printer: result?.printer };
}
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -61,9 +61,8 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
* @param message The log message.
* @param additionalContext Optional context data specific to this log message.
*/
trace: (message: string, additionalContext?: LoggerContext): void => {
loggingService.trace(
message,
trace: (message: string, additionalContext?: () => LoggerContext): void => {
loggingService.trace(message, () =>
mergeContexts(context, ctxFn, additionalContext),
);
},
@@ -72,9 +71,8 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
* @param message The log message.
* @param additionalContext Optional context data specific to this log message.
*/
debug: (message: string, additionalContext?: LoggerContext): void => {
loggingService.debug(
message,
debug: (message: string, additionalContext?: () => LoggerContext): void => {
loggingService.debug(message, () =>
mergeContexts(context, ctxFn, additionalContext),
);
},
@@ -83,9 +81,8 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
* @param message The log message.
* @param additionalContext Optional context data specific to this log message.
*/
info: (message: string, additionalContext?: LoggerContext): void => {
loggingService.info(
message,
info: (message: string, additionalContext?: () => LoggerContext): void => {
loggingService.info(message, () =>
mergeContexts(context, ctxFn, additionalContext),
);
},
@@ -94,9 +91,8 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
* @param message The log message.
* @param additionalContext Optional context data specific to this log message.
*/
warn: (message: string, additionalContext?: LoggerContext): void => {
loggingService.warn(
message,
warn: (message: string, additionalContext?: () => LoggerContext): void => {
loggingService.warn(message, () =>
mergeContexts(context, ctxFn, additionalContext),
);
},
@@ -109,11 +105,9 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
error: (
message: string,
error?: Error,
additionalContext?: LoggerContext,
additionalContext?: () => LoggerContext,
): void => {
loggingService.error(
message,
error,
loggingService.error(message, error, () =>
mergeContexts(context, ctxFn, additionalContext),
);
},
@@ -135,7 +129,7 @@ export function logger(ctxFn?: () => LoggerContext): LoggerApi {
function mergeContexts(
baseContext: LoggerContext[] | null,
injectorContext?: () => LoggerContext,
additionalContext?: LoggerContext,
additionalContext?: () => LoggerContext,
): LoggerContext {
const contextArray = Array.isArray(baseContext) ? baseContext : [];
@@ -145,9 +139,7 @@ function mergeContexts(
contextArray.push(injectorCtx);
}
if (typeof additionalContext === 'object') {
contextArray.push(additionalContext);
}
contextArray.push(additionalContext ? additionalContext() : {});
if (!contextArray.length) {
return {};

View File

@@ -106,7 +106,7 @@ export class LoggingService implements LoggerApi {
* @param message - The message to log
* @param context - Optional metadata or structured data to include
*/
trace(message: string, context?: LoggerContext): void {
trace(message: string, context?: () => LoggerContext): void {
this.log(LogLevel.Trace, message, context);
}
@@ -117,7 +117,7 @@ export class LoggingService implements LoggerApi {
* @param message - The message to log
* @param context - Optional metadata or structured data to include
*/
debug(message: string, context?: LoggerContext): void {
debug(message: string, context?: () => LoggerContext): void {
this.log(LogLevel.Debug, message, context);
}
@@ -128,7 +128,7 @@ export class LoggingService implements LoggerApi {
* @param message - The message to log
* @param context - Optional metadata or structured data to include
*/
info(message: string, context?: LoggerContext): void {
info(message: string, context?: () => LoggerContext): void {
this.log(LogLevel.Info, message, context);
}
@@ -139,7 +139,7 @@ export class LoggingService implements LoggerApi {
* @param message - The message to log
* @param context - Optional metadata or structured data to include
*/
warn(message: string, context?: LoggerContext): void {
warn(message: string, context?: () => LoggerContext): void {
this.log(LogLevel.Warn, message, context);
}
@@ -164,7 +164,7 @@ export class LoggingService implements LoggerApi {
* }
* ```
*/
error(message: string, error?: Error, context?: LoggerContext): void {
error(message: string, error?: Error, context?: () => LoggerContext): void {
this.log(LogLevel.Error, message, context, error);
}
@@ -178,7 +178,7 @@ export class LoggingService implements LoggerApi {
private log(
level: LogLevel,
message: string,
context?: LoggerContext,
context?: () => LoggerContext,
error?: Error,
): void {
// Short-circuit if logging is disabled or level is too low (performance optimization)
@@ -192,7 +192,7 @@ export class LoggingService implements LoggerApi {
}
// Merge global context with the provided context
const mergedContext = this.mergeContext(context);
const mergedContext = this.mergeContext(context?.());
// Send to all sinks
for (const sink of this.sinks) {

View File

@@ -108,25 +108,25 @@ export interface LoggerApi {
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: LoggerContext): void;
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: LoggerContext): void;
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: LoggerContext): void;
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: LoggerContext): void;
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
@@ -136,7 +136,7 @@ export interface LoggerApi {
* @param error - Any error object or value that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error: unknown, context?: LoggerContext): void;
error(message: string, error: unknown, context?: () => LoggerContext): void;
}
/**

View File

View File

View File

View File

View File

View File

@@ -14,14 +14,9 @@
*/
export * from './lib/errors';
export * from './lib/questions';
export * from './lib/models';
export * from './lib/helpers/return-process';
export * from './lib/schemas';
export * from './lib/return-details.service';
export * from './lib/return-details.store';
export * from './lib/return-process.service';
export * from './lib/return-process.store';
export * from './lib/return-search.service';
export * from './lib/return-search.store';
export * from './lib/return-print-receipts.service';
export * from './lib/return-task-list.service';
export * from './lib/return-task-list.store';
export * from './lib/services';
export * from './lib/stores';

View File

@@ -1,5 +1,5 @@
import { DataAccessError } from '@isa/common/data-access';
import { ReceiptItem } from '../../models';
import { Receipt, ReceiptItem } from '../../models';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
@@ -9,7 +9,7 @@ import {
describe('CreateReturnProcessError', () => {
const params = {
processId: 123,
receiptId: 456,
receipt: { id: 321 } as Receipt,
items: [] as ReceiptItem[],
};

View File

@@ -0,0 +1 @@
export * from './is-return-process-type.guard';

View File

@@ -0,0 +1,18 @@
import { ReturnProcess } from '../models';
export function isReturnProcessTypeGuard(
input: unknown,
): input is ReturnProcess {
if (typeof input !== 'object' || input === null) return false;
const inp = input as Partial<ReturnProcess>;
return (
typeof inp.id === 'number' &&
typeof inp.processId === 'number' &&
typeof inp.receiptId === 'number' &&
typeof inp.answers === 'object' &&
inp.answers !== null &&
'receiptItem' in inp
);
}

View File

@@ -35,7 +35,7 @@ export function activeReturnProcessQuestions(
return internalActiveReturnProcessQuestions(questions, answers).questions;
}
function internalActiveReturnProcessQuestions(
export function internalActiveReturnProcessQuestions(
questions: ReturnProcessQuestion[],
answers: ReturnProcessAnswers,
): { questions: ReturnProcessQuestion[]; nextQuestion?: string } {

View File

@@ -108,7 +108,7 @@ describe('internalCalculateLongestQuestionDepth', () => {
[ReturnProcessQuestionKey.ItemCondition]: ItemConditionAnswer.Damaged,
};
const expectedDepth = 8;
const expectedDepth = 9;
const result = helpers.calculateLongestQuestionDepth(
tolinoQuestions,

View File

@@ -2,37 +2,95 @@ import {
EligibleForReturn,
EligibleForReturnState,
ReturnProcess,
ReturnProcessQuestion,
ReturnProcessQuestionKey,
} from '../../models';
import { ItemConditionAnswer, YesNoAnswer } from '../../questions';
import { parseISO, differenceInCalendarDays } from 'date-fns';
import {
ItemConditionAnswer,
ReturnReasonAnswer,
YesNoAnswer,
} from '../../questions';
import {
parseISO,
differenceInCalendarDays,
differenceInCalendarMonths,
} from 'date-fns';
// TODO: Tolino special cases implementieren (Verschiedene Antwortmöglichkeiten)
// #4978
export const isTolinoEligibleForReturn = (
returnProcess: ReturnProcess,
questions: ReturnProcessQuestion[],
): EligibleForReturn | undefined => {
console.log(returnProcess);
const answers = returnProcess.answers;
const date = returnProcess.receiptDate;
// Check if question exists before evaluating its answer
if (questions.find((q) => q.key === ReturnProcessQuestionKey.ItemCondition)) {
const itemCondition = answers[ReturnProcessQuestionKey.ItemCondition];
const receiptOlderThan100Days = date
? differenceInCalendarDays(new Date(), parseISO(date)) >= 100
: undefined;
if (itemCondition === ItemConditionAnswer.OVP && receiptOlderThan100Days) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
}
}
// Check if question exists before evaluating its answer
if (questions.find((q) => q.key === ReturnProcessQuestionKey.ItemDamaged)) {
const itemDamaged = answers[ReturnProcessQuestionKey.ItemDamaged];
if (itemDamaged === ReturnReasonAnswer.DamagedByCustomer) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
}
const receiptOlderThan6Months = date
? differenceInCalendarMonths(new Date(), parseISO(date)) >= 6
: undefined;
if (
itemDamaged === ReturnReasonAnswer.ReceivedDamaged &&
receiptOlderThan6Months
) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
}
}
return { state: EligibleForReturnState.Eligible };
};
// #4978
export const isElektronischeGeraeteEligibleForReturn = (
returnProcess: ReturnProcess,
questions: ReturnProcessQuestion[],
): EligibleForReturn | undefined => {
const answers = returnProcess.answers;
const date = returnProcess.receiptDate;
const itemCondition = answers[ReturnProcessQuestionKey.ItemCondition];
const receiptOlderThan100Days = date
? differenceInCalendarDays(new Date(), parseISO(date)) >= 100
: undefined;
// Check if question exists before evaluating its answer
if (questions.find((q) => q.key === ReturnProcessQuestionKey.ItemCondition)) {
const itemCondition = answers[ReturnProcessQuestionKey.ItemCondition];
if (itemCondition === ItemConditionAnswer.OVP && receiptOlderThan100Days) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
const receiptOlderThan100Days = date
? differenceInCalendarDays(new Date(), parseISO(date)) >= 100
: undefined;
if (itemCondition === ItemConditionAnswer.OVP && receiptOlderThan100Days) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
}
}
return { state: EligibleForReturnState.Eligible };
};
@@ -40,18 +98,27 @@ export const isElektronischeGeraeteEligibleForReturn = (
// #4978
export const isTonDatentraegerEligibleForReturn = (
returnProcess: ReturnProcess,
questions: ReturnProcessQuestion[],
): EligibleForReturn | undefined => {
const answers = returnProcess.answers;
const itemCondition = answers[ReturnProcessQuestionKey.ItemCondition];
const itemDefective = answers[ReturnProcessQuestionKey.ItemDefective];
// Check if questions exist before evaluating their answers
if (
itemCondition === ItemConditionAnswer.Damaged &&
itemDefective === YesNoAnswer.No
questions.find((q) => q.key === ReturnProcessQuestionKey.ItemCondition) &&
questions.find((q) => q.key === ReturnProcessQuestionKey.ItemDefective)
) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
const itemCondition = answers[ReturnProcessQuestionKey.ItemCondition];
const itemDefective = answers[ReturnProcessQuestionKey.ItemDefective];
if (
itemCondition === ItemConditionAnswer.Damaged &&
itemDefective === YesNoAnswer.No
) {
return {
state: EligibleForReturnState.NotEligible,
reason: 'Keine Retoure möglich',
};
}
}
return { state: EligibleForReturnState.Eligible };
};

View File

@@ -7,9 +7,13 @@ import {
ReturnProcessQuestionKey,
ReturnProcessQuestionType,
} from '../../models';
import { activeReturnProcessQuestions } from './active-return-process-questions.helper';
import {
activeReturnProcessQuestions,
internalActiveReturnProcessQuestions,
} from './active-return-process-questions.helper';
import { allReturnProcessQuestionsAnswered } from './all-return-process-questions-answered.helper';
import { ReturnProcessQuestionSchema } from '../../questions';
import { ReturnProcessChecklistAnswerSchema } from '../../schemas';
/**
* Union type for parameters accepted by getReturnInfo.
@@ -113,7 +117,6 @@ export function getReturnInfo(
currentQuestionKey = selectedOption?.nextQuestion;
break;
}
// TODO: Checkliste handling
case ReturnProcessQuestionType.Product: {
const parseResult = ReturnProcessQuestionSchema[
ReturnProcessQuestionKey.DeliveredItem
@@ -128,18 +131,50 @@ export function getReturnInfo(
currentQuestionKey = question.nextQuestion;
break;
}
case ReturnProcessQuestionType.Checklist: {
const parseResult =
ReturnProcessChecklistAnswerSchema.passthrough().safeParse(answer);
if (parseResult.success) {
const selectedValues: string[] = [...parseResult.data.options];
if (parseResult.data.other) {
selectedValues.push(parseResult.data.other);
}
assignReturnInfo(returnInfo, {
returnDetails: {
[question.key]: selectedValues,
},
});
}
currentQuestionKey = question.nextQuestion;
break;
}
case ReturnProcessQuestionType.Info: {
currentQuestionKey = question.nextQuestion;
break;
}
case ReturnProcessQuestionType.Group: {
const f = internalActiveReturnProcessQuestions(
question.questions,
answers,
);
const groupReturnInfo = getReturnInfo({
questions: question.questions,
questions: f.questions,
answers,
});
if (groupReturnInfo) {
assignReturnInfo(returnInfo, groupReturnInfo);
}
currentQuestionKey = f.nextQuestion as ReturnProcessQuestionKey;
break;
}
default: {
throw new Error(`Unsupported question type: ${question.type}`);
throw new Error(`Unsupported question type`);
}
}
}
@@ -147,7 +182,39 @@ export function getReturnInfo(
return returnInfo;
}
// TODO: Je nachdem wie das Backend die Werte braucht hier drin anpassen
/**
* Assigns all properties from `source` into `target`, merging `returnDetails` specially.
*
* @param target - The object to receive assigned properties.
* @param source - The object providing properties to assign.
*
* @remarks
* - If `source.returnDetails` is `undefined`, performs a straight shallow merge of all fields.
* - Otherwise:
* 1. Merges `returnDetails` by combining any existing entries in `target.returnDetails` with
* those from `source.returnDetails`.
* 2. Shallow-assigns all other properties from `source` into `target`, leaving `returnDetails`
* untouched (since it was already merged).
*/
function assignReturnInfo(target: ReturnInfo, source: ReturnInfo) {
Object.assign(target, source);
// 1) If there are no returnDetails in source, copy everything at once.
if (!source.returnDetails) {
Object.assign(target, source);
return;
}
// 2) Merge returnDetails:
// - Keep existing entries in target.returnDetails (if any)
// - Override/add entries from source.returnDetails
target.returnDetails = {
...(target.returnDetails ?? {}),
...source.returnDetails,
};
// 3) Prepare all other fields (excluding returnDetails) for assignment
const otherFields: Partial<ReturnInfo> = { ...source };
delete otherFields.returnDetails;
// 4) Shallow-merge the rest of the properties into target
Object.assign(target, otherFields);
}

View File

@@ -0,0 +1,29 @@
import { ReturnProcess, ReturnProcessQuestion } from '../../models';
import { CategoryQuestions, ProductCategory } from '../../questions';
import { tolinoQuestions } from './get-tolino-questions.helper';
/**
* Returns the list of return process questions based on the product category.
*
* If the product category is `Tolino`, additional dynamic logic is applied
* via `tolinoQuestions(process)` to adjust the question flow based on prior answers.
* For all other categories, the predefined question set from `CategoryQuestions` is returned.
*
* If the product category is missing or invalid, `undefined` is returned.
*
* @param process - The current return process state, including the product category and answers.
* @returns The appropriate list of `ReturnProcessQuestion`s for the given process,
* or `undefined` if no valid category is present.
*/
export const getReturnProcessQuestions = (
process: ReturnProcess,
): ReturnProcessQuestion[] | undefined => {
const category = process.productCategory as ProductCategory;
if (!category) return undefined;
if (category === ProductCategory.Tolino) {
return tolinoQuestions(process);
}
return CategoryQuestions[category as keyof typeof CategoryQuestions];
};

View File

@@ -0,0 +1,110 @@
import {
ReturnProcess,
ReturnProcessQuestion,
ReturnProcessQuestionKey,
ReturnProcessQuestionSelectOptions,
ReturnProcessSelectQuestion,
} from '../../models';
import {
CategoryQuestions,
ProductCategory,
YesNoAnswer,
} from '../../questions';
/**
* Dynamically returns the question set for the Tolino return process
* based on user input collected so far (`process.answers`).
*
* This function adjusts the flow of questions by updating the `nextQuestion`
* of the "Item Defective" questions "No" option, depending on the
* answers to "Device Power" and "Item Defective".
*
* This dynamic behavior ensures that the correct follow-up question
* is shown depending on prior selections, making the return flow conditional
* and tailored to the specific situation of the customer.
*
* @param process - The current return process state, including previously answered questions.
* @returns A list of updated `ReturnProcessQuestion`s for the Tolino category.
*/
export const tolinoQuestions = (
process: ReturnProcess,
): ReturnProcessQuestion[] => {
const questions = CategoryQuestions[ProductCategory.Tolino];
const devicePower = process.answers[
ReturnProcessQuestionKey.DevicePower
] as YesNoAnswer;
const itemDefective = process.answers[
ReturnProcessQuestionKey.ItemDefective
] as YesNoAnswer;
const itemDefectiveOption = getSelectOptionFromQuestion(
questions,
ReturnProcessQuestionKey.ItemDefective,
YesNoAnswer.No,
);
itemDefectiveOption.nextQuestion = getNextQuestionKey(
devicePower,
itemDefective,
);
return questions;
};
/**
* Retrieves a specific option from a select-type question in a given question list.
*
* Throws an error if the question or the option does not exist,
* ensuring that the returned value is always valid and non-undefined.
*
* @param questions - The list of questions to search within.
* @param questionKey - The key identifying the target question.
* @param optionValue - The value of the option to retrieve from the question.
* @returns The matching `ReturnProcessQuestionSelectOptions` object.
*/
const getSelectOptionFromQuestion = (
questions: ReturnProcessQuestion[],
questionKey: ReturnProcessQuestionKey,
optionValue: string,
): ReturnProcessQuestionSelectOptions => {
const question = questions.find((q) => q.key === questionKey);
if (!question) throw new Error(`Question '${questionKey}' not found`);
const option = (question as ReturnProcessSelectQuestion).options.find(
(o) => o.value === optionValue,
);
if (!option)
throw new Error(
`Option '${optionValue}' not found in question '${questionKey}'`,
);
return option;
};
/**
* Determines the next question in the Tolino return process flow
* based on the combination of answers to "Device Power" and "Item Defective".
*
* This logic defines the branching decision for what should follow
* depending on the customers answers.
*
* @param devicePower - Whether the device powers on (Yes or No).
* @param itemDefective - Whether the item is defective (Yes or No).
* @returns The key of the next question in the process flow.
*/
const getNextQuestionKey = (
devicePower: YesNoAnswer,
itemDefective: YesNoAnswer,
): ReturnProcessQuestionKey => {
if (devicePower === YesNoAnswer.Yes && itemDefective === YesNoAnswer.No) {
return ReturnProcessQuestionKey.ReturnReason;
} else if (
devicePower === YesNoAnswer.No &&
itemDefective === YesNoAnswer.No
) {
return ReturnProcessQuestionKey.ItemDamaged;
} else {
return ReturnProcessQuestionKey.CaseDamaged;
}
};

View File

@@ -4,3 +4,7 @@ export * from './calculate-longest-question-depth.helper';
export * from './get-next-question.helper';
export * from './get-return-info.helper';
export * from './eligible-for-return.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';

View File

@@ -0,0 +1,138 @@
import { serializeReturnDetails, returnDetailsMapping } from './return-details-mapping.helper';
// Mock ReturnProcessQuestionKey for testing purposes
enum MockReturnProcessQuestionKey {
Reason = 'reason',
Quantity = 'quantity',
Comments = 'comments',
Tags = 'tags',
}
describe('serializeReturnDetails', () => {
it('should return undefined for undefined input', () => {
// Arrange
const input = undefined;
// Act
const result = serializeReturnDetails(input as any);
// Assert
expect(result).toBeUndefined();
});
it('should return undefined for empty object', () => {
// Arrange
const input = {};
// Act
const result = serializeReturnDetails(input as any);
// Assert
expect(result).toBeUndefined();
});
it('should serialize single key-value pair', () => {
// Arrange
const input = { [MockReturnProcessQuestionKey.Reason]: 'Damaged' };
// Act
const result = serializeReturnDetails(input as any);
// Assert
expect(result).toBe('reason:Damaged');
});
it('should serialize multiple key-value pairs', () => {
// Arrange
const input = {
[MockReturnProcessQuestionKey.Reason]: 'Wrong Size',
[MockReturnProcessQuestionKey.Quantity]: 2,
};
// Act
const result = serializeReturnDetails(input as any);
// Assert
expect(result).toBe('reason:Wrong Size;quantity:2');
});
it('should serialize values of different types', () => {
// Arrange
const input = {
[MockReturnProcessQuestionKey.Quantity]: 5,
[MockReturnProcessQuestionKey.Comments]: null,
[MockReturnProcessQuestionKey.Tags]: ['urgent', 123],
};
// Act
const result = serializeReturnDetails(input as any);
// Assert
expect(result).toBe('quantity:5;comments:null;tags:urgent,123');
});
});
describe('returnDetailsMapping', () => {
it('should return empty array for empty object', () => {
// Arrange
const input = {};
// Act
const result = returnDetailsMapping(input as any);
// Assert
expect(result).toEqual([]);
});
it('should map single key-value pair', () => {
// Arrange
const input = { [MockReturnProcessQuestionKey.Reason]: 'Defective' };
// Act
const result = returnDetailsMapping(input as any);
// Assert
expect(result).toEqual(['reason: Defective']);
});
it('should map multiple key-value pairs', () => {
// Arrange
const input = {
[MockReturnProcessQuestionKey.Reason]: 'Other',
[MockReturnProcessQuestionKey.Quantity]: 1,
};
// Act
const result = returnDetailsMapping(input as any);
// Assert
expect(result).toEqual(['reason: Other', 'quantity: 1']);
});
it('should join array values with comma and space', () => {
// Arrange
const input = {
[MockReturnProcessQuestionKey.Tags]: ['fragile', 'return', 42],
};
// Act
const result = returnDetailsMapping(input as any);
// Assert
expect(result).toEqual(['tags: fragile, return, 42']);
});
it('should handle null and undefined values', () => {
// Arrange
const input = {
[MockReturnProcessQuestionKey.Comments]: null,
[MockReturnProcessQuestionKey.Quantity]: undefined,
};
// Act
const result = returnDetailsMapping(input as any);
// Assert
expect(result).toEqual(['comments: null', 'quantity: undefined']);
});
});

View File

@@ -0,0 +1,45 @@
import { ReturnProcessQuestionKey } from '../../models';
/**
* Serializes a return details object into a string representation.
*
* @param returnDetails - An object mapping ReturnProcessQuestionKey to values. May be undefined.
* @returns A semicolon-separated string of key:value pairs, or undefined if input is empty or undefined.
*/
export const serializeReturnDetails = (
returnDetails: Partial<Record<ReturnProcessQuestionKey, unknown>> | undefined,
): string | undefined => {
if (!returnDetails || Object.keys(returnDetails).length === 0) {
return undefined;
}
return Object.entries(returnDetails)
.map(([key, value]) => `${key}:${String(value)}`)
.join(';');
};
/**
* Maps a return details object to an array of formatted strings.
*
* @param returnDetails - An object mapping ReturnProcessQuestionKey to values.
* @returns An array of strings in the format "key: value", with arrays joined by comma and space.
*/
export const returnDetailsMapping = (
returnDetails: Partial<Record<ReturnProcessQuestionKey, unknown>>,
): string[] => {
const entries = Object.entries(returnDetails) as [
ReturnProcessQuestionKey,
unknown,
][];
const mapped: string[] = [];
for (const [key, value] of entries) {
const valString = Array.isArray(value)
? value.map((v) => String(v)).join(', ')
: String(value);
mapped.push(`${key}: ${valString}`);
}
return mapped;
};

View File

@@ -0,0 +1,113 @@
import { returnReceiptValuesMapping } from './return-receipt-values-mapping.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { serializeReturnDetails } from './return-details-mapping.helper';
// Mock dependencies
jest.mock('./get-return-process-questions.helper', () => ({
getReturnProcessQuestions: jest.fn(),
}));
jest.mock('./get-return-info.helper', () => ({
getReturnInfo: jest.fn(),
}));
jest.mock('./return-details-mapping.helper', () => ({
serializeReturnDetails: jest.fn(),
}));
describe('returnReceiptValuesMapping', () => {
const processMock: any = {
receiptItem: {
id: 'item-1',
quantity: { quantity: 2 },
features: { category: 'shoes' },
},
answers: { foo: 'bar' },
};
const questionsMock = [{ id: 'q1' }];
const returnInfoMock = {
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
returnDetails: { detail: 'details' },
returnReason: 'Damaged',
};
const serializedDetails = { detail: 'serialized' };
beforeEach(() => {
jest.clearAllMocks();
(getReturnProcessQuestions as jest.Mock).mockReturnValue(questionsMock);
(getReturnInfo as jest.Mock).mockReturnValue(returnInfoMock);
(serializeReturnDetails as jest.Mock).mockReturnValue(serializedDetails);
});
it('should map values correctly when all dependencies return valid data', () => {
// Act
const result = returnReceiptValuesMapping(processMock);
// Assert
expect(result).toEqual({
quantity: 2,
comment: 'Test comment',
itemCondition: 'NEW',
otherProduct: 'Other',
returnDetails: serializedDetails,
returnReason: 'Damaged',
category: 'shoes',
receiptItem: { id: 'item-1' },
});
expect(getReturnProcessQuestions).toHaveBeenCalledWith(processMock);
expect(getReturnInfo).toHaveBeenCalledWith({
questions: questionsMock,
answers: processMock.answers,
});
expect(serializeReturnDetails).toHaveBeenCalledWith(
returnInfoMock.returnDetails,
);
});
it('should throw PropertyNullOrUndefinedError if questions is undefined', () => {
// Arrange
(getReturnProcessQuestions as jest.Mock).mockReturnValue(undefined);
// Act & Assert
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('questions');
});
it('should throw PropertyNullOrUndefinedError if returnInfo is undefined', () => {
// Arrange
(getReturnInfo as jest.Mock).mockReturnValue(undefined);
// Act & Assert
expect(() => returnReceiptValuesMapping(processMock)).toThrow(
PropertyNullOrUndefinedError,
);
expect(() => returnReceiptValuesMapping(processMock)).toThrow('returnInfo');
});
it('should handle missing category gracefully', () => {
// Arrange
const processNoCategory = {
...processMock,
receiptItem: { ...processMock.receiptItem, features: {} },
};
// Act
const result = returnReceiptValuesMapping(processNoCategory);
// Assert
expect(result?.category).toBeUndefined();
});
it('should handle missing receiptItem gracefully (may throw)', () => {
// Arrange
const processNoReceiptItem = { ...processMock, receiptItem: undefined };
// Act & Assert
expect(() => returnReceiptValuesMapping(processNoReceiptItem)).toThrow();
});
});

View File

@@ -0,0 +1,37 @@
import { ReturnProcess } from '../../models';
import { ReturnReceiptValues } from '../../schemas';
import { getReturnProcessQuestions } from './get-return-process-questions.helper';
import { getReturnInfo } from './get-return-info.helper';
import { PropertyNullOrUndefinedError } from '@isa/common/data-access';
import { serializeReturnDetails } from './return-details-mapping.helper';
export const returnReceiptValuesMapping = (
process: ReturnProcess,
): ReturnReceiptValues | undefined => {
const questions = getReturnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
const returnInfo = getReturnInfo({
questions,
answers: process.answers,
});
if (!returnInfo) {
throw new PropertyNullOrUndefinedError('returnInfo');
}
return {
quantity: process.receiptItem.quantity.quantity,
comment: returnInfo.comment,
itemCondition: returnInfo.itemCondition,
otherProduct: returnInfo.otherProduct,
returnDetails: serializeReturnDetails(returnInfo.returnDetails),
returnReason: returnInfo.returnReason,
category: process?.receiptItem?.features?.['category'],
receiptItem: {
id: process.receiptItem.id,
},
};
};

View File

@@ -1,13 +0,0 @@
export * from './errors';
export * from './models';
export * from './questions';
export * from './return-details.service';
export * from './return-details.store';
export * from './return-print-receipts.service';
export * from './return-process.service';
export * from './return-process.store';
export * from './return-search.service';
export * from './return-search.store';
export * from './return-task-list.service';
export * from './return-task-list.store';
export * from './schemas';

View File

@@ -1,4 +1,5 @@
import { Product } from './product';
import { ReturnProcessQuestionKey } from './return-process-question-key';
/**
* Interface representing information collected during a return process.
@@ -24,7 +25,7 @@ export interface ReturnInfo {
/**
* Return details / Rückgabedetails
*/
returnDetails?: string;
returnDetails?: Partial<Record<ReturnProcessQuestionKey, unknown>>;
/**
* Return reason / Rückgabegrund

View File

@@ -1,17 +1,17 @@
export const ReturnProcessQuestionKey = {
ItemCondition: 'item_condition',
ReturnReason: 'return_reason',
DeliveredItem: 'delivered_item',
ItemDefective: 'item_defective',
DisplayCondition: 'display_condition',
DevicePower: 'device_power',
PackageComplete: 'package_complete',
ItemCondition: 'Artikelzustand',
ReturnReason: 'Rückgabegrund',
DeliveredItem: 'Gelieferter Artikel',
ItemDefective: 'Kleinere Mängel',
DisplayDamaged: 'Display beschädigt',
DevicePower: 'Gerät einschaltbar',
PackageComplete: 'Verpackung vollständig',
PackageCompleteGroup: 'package_complete_group',
PackageIncompleteInfo: 'info',
PackageIncomplete: 'package_incomplete',
CaseCondition: 'case_condition',
UsbPort: 'usb_port',
ItemDamaged: 'item_damaged',
PackageMissingItems: 'Fehlende Artikel',
CaseDamaged: 'Gehäuse beschädigt',
UsbPortDamaged: 'USB-Anschluss beschädigt',
ItemDamaged: 'Beschädigungs Ursache',
} as const;
export type ReturnProcessQuestionKey =

View File

@@ -11,24 +11,24 @@ import { ReturnProcessQuestionKey } from '../models';
describe('Constants', () => {
describe('Return Process Answers', () => {
it('should define ItemConditionAnswer values', () => {
expect(ItemConditionAnswer.OVP).toBe('ovp');
expect(ItemConditionAnswer.Damaged).toBe('damaged');
expect(ItemConditionAnswer.OVP).toBe('Originalverpackt');
expect(ItemConditionAnswer.Damaged).toBe('Geöffnet/Defekt');
});
it('should define ReturnReasonAnswer values', () => {
expect(ReturnReasonAnswer.Dislike).toBe('dislike');
expect(ReturnReasonAnswer.WrongItem).toBe('wrong_item');
expect(ReturnReasonAnswer.Dislike).toBe('Gefällt nicht/Widerruf');
expect(ReturnReasonAnswer.WrongItem).toBe('Fehllieferung');
});
it('should define YesNoAnswer values', () => {
expect(YesNoAnswer.Yes).toBe('yes');
expect(YesNoAnswer.No).toBe('no');
expect(YesNoAnswer.Yes).toBe('Ja');
expect(YesNoAnswer.No).toBe('Nein');
});
it('should define PackageIncompleteAnswer values', () => {
expect(PackageIncompleteAnswer.OVP).toBe('ovp');
expect(PackageIncompleteAnswer.ChargingCable).toBe('charging_cable');
expect(PackageIncompleteAnswer.QuickStartGuide).toBe('quick_start_guide');
expect(PackageIncompleteAnswer.OVP).toBe('Karton / Umverpackung');
expect(PackageIncompleteAnswer.ChargingCable).toBe('Ladekabel');
expect(PackageIncompleteAnswer.QuickStartGuide).toBe('Quickstart-Guide');
});
});
@@ -75,11 +75,11 @@ describe('Constants', () => {
it('should validate YesNo schemas', () => {
const schemas = [
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.ItemDefective],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.DisplayCondition],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.DisplayDamaged],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.DevicePower],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.PackageComplete],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.CaseCondition],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.UsbPort],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.CaseDamaged],
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.UsbPortDamaged],
];
schemas.forEach((schema) => {
@@ -94,7 +94,9 @@ describe('Constants', () => {
it('should validate PackageIncomplete schema', () => {
const schema =
ReturnProcessQuestionSchema[ReturnProcessQuestionKey.PackageIncomplete];
ReturnProcessQuestionSchema[
ReturnProcessQuestionKey.PackageMissingItems
];
// Valid values - with options
expect(

View File

@@ -11,7 +11,6 @@ export const ProductCategory = {
SonstigesNonbook: 'Sonstiges und Non-Book',
ElektronischeGeraete: 'Andere Elektronische Geräte',
Tolino: 'Tolino',
Software: 'Software',
} as const;
export type ProductCategoryKey = keyof typeof ProductCategory;
@@ -25,8 +24,8 @@ export type ProductCategory =
* - Damaged: Item is damaged or has printing errors
*/
export const ItemConditionAnswer = {
OVP: 'ovp',
Damaged: 'damaged',
OVP: 'Originalverpackt',
Damaged: 'Geöffnet/Defekt',
} as const;
/**
@@ -42,10 +41,10 @@ export type ItemConditionAnswer =
* - WrongItem: Customer received the wrong item (incorrect delivery)
*/
export const ReturnReasonAnswer = {
Dislike: 'dislike',
WrongItem: 'wrong_item',
DamagedByCustomer: 'damaged_by_customer',
ReceivedDamaged: 'received_damaged',
Dislike: 'Gefällt nicht/Widerruf',
WrongItem: 'Fehllieferung',
DamagedByCustomer: 'Beschädigt durch Kunde',
ReceivedDamaged: 'Defekt bei Erhalt',
} as const;
/**
@@ -60,8 +59,8 @@ export type ReturnReasonAnswer =
* Used for boolean-type questions throughout the return process flow.
*/
export const YesNoAnswer = {
Yes: 'yes',
No: 'no',
Yes: 'Ja',
No: 'Nein',
} as const;
/**
@@ -78,9 +77,9 @@ export type YesNoAnswer = (typeof YesNoAnswer)[keyof typeof YesNoAnswer];
* - QuickStartGuide: Quick start guide documentation is missing
*/
export const PackageIncompleteAnswer = {
OVP: 'ovp',
ChargingCable: 'charging_cable',
QuickStartGuide: 'quick_start_guide',
OVP: 'Karton / Umverpackung',
ChargingCable: 'Ladekabel',
QuickStartGuide: 'Quickstart-Guide',
} as const;
/**
@@ -120,7 +119,7 @@ export const ReturnProcessQuestionSchema = {
YesNoAnswer.Yes,
YesNoAnswer.No,
]),
[ReturnProcessQuestionKey.DisplayCondition]: z.enum([
[ReturnProcessQuestionKey.DisplayDamaged]: z.enum([
YesNoAnswer.Yes,
YesNoAnswer.No,
]),
@@ -138,7 +137,7 @@ export const ReturnProcessQuestionSchema = {
]),
[ReturnProcessQuestionKey.PackageCompleteGroup]: z.any(),
[ReturnProcessQuestionKey.PackageIncompleteInfo]: z.any(),
[ReturnProcessQuestionKey.PackageIncomplete]: z
[ReturnProcessQuestionKey.PackageMissingItems]: z
.object({
options: z
.array(
@@ -165,11 +164,14 @@ export const ReturnProcessQuestionSchema = {
other: z.string(),
}),
),
[ReturnProcessQuestionKey.CaseCondition]: z.enum([
[ReturnProcessQuestionKey.CaseDamaged]: z.enum([
YesNoAnswer.Yes,
YesNoAnswer.No,
]),
[ReturnProcessQuestionKey.UsbPortDamaged]: z.enum([
YesNoAnswer.Yes,
YesNoAnswer.No,
]),
[ReturnProcessQuestionKey.UsbPort]: z.enum([YesNoAnswer.Yes, YesNoAnswer.No]),
[ReturnProcessQuestionKey.DeliveredItem]: z
.object({
catalogProductNumber: z.string().optional(),

View File

@@ -3,11 +3,7 @@ import {
ReturnProcessQuestionKey,
ReturnProcessQuestionType,
} from '../models';
import {
ItemConditionAnswer,
ReturnReasonAnswer,
YesNoAnswer,
} from './constants';
import { ItemConditionAnswer, ReturnReasonAnswer } from './constants';
/**
* Questions for the return process of other electronic devices.

View File

@@ -26,5 +26,4 @@ export const CategoryQuestions: Record<
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
[ProductCategory.Tolino]: tolinoQuestions,
[ProductCategory.Software]: tonDatentraegerQuestions,
};

View File

@@ -0,0 +1,201 @@
import {
ReturnProcessQuestion,
ReturnProcessQuestionKey,
ReturnProcessQuestionType,
} from '../models';
import { tolinoQuestions } from './tolino';
describe('tolinoQuestions consistency & flow', () => {
let questionMap: Record<string, ReturnProcessQuestion> = {};
beforeAll(() => {
questionMap = {};
for (const q of tolinoQuestions) {
questionMap[q.key] = q;
if (q.type === ReturnProcessQuestionType.Group) {
for (const sub of q.questions) {
questionMap[sub.key] = sub;
}
}
}
});
it('every Select option defines a valid nextQuestion (or is terminal)', () => {
// Update terminal select keys to match the actual keys in the code
const terminalSelectKeys = [
ReturnProcessQuestionKey.ItemDamaged,
ReturnProcessQuestionKey.ReturnReason,
ReturnProcessQuestionKey.ItemDefective, // Add this key as it appears to be a terminal select now
];
for (const q of tolinoQuestions) {
if (q.type === ReturnProcessQuestionType.Select) {
for (const opt of q.options) {
if (opt.nextQuestion == null) {
// allowed only on the terminal selects
expect(terminalSelectKeys).toContain(q.key);
} else {
expect(questionMap[opt.nextQuestion]).toBeDefined();
}
}
}
}
});
it('Info and Checklist questions inside groups have nextQuestion', () => {
for (const q of tolinoQuestions) {
if (q.type === ReturnProcessQuestionType.Group) {
for (const sub of q.questions) {
if (
sub.type === ReturnProcessQuestionType.Info ||
sub.type === ReturnProcessQuestionType.Checklist
) {
const next = (sub as any).nextQuestion;
expect(next).toBeDefined();
expect(questionMap[next]).toBeDefined();
}
}
}
}
});
it('no cycles and all paths end at DeliveredItem', () => {
const terminal = ReturnProcessQuestionKey.DeliveredItem;
// Update terminal selects to match the actual keys in the code
const terminalSelects = [
ReturnProcessQuestionKey.ItemDamaged,
ReturnProcessQuestionKey.ReturnReason,
ReturnProcessQuestionKey.ItemDefective, // Add this key as it appears to be a terminal select now
];
const walk = (key: ReturnProcessQuestionKey, visited: Set<string>) => {
expect(visited.has(key)).toBeFalsy();
visited.add(key);
if (key === terminal) return;
const q = questionMap[key];
switch (q.type) {
case ReturnProcessQuestionType.Select:
for (const opt of q.options) {
if (opt.nextQuestion) {
walk(opt.nextQuestion, new Set(visited));
} else {
// terminal Select must be one of the allowed ones
expect(terminalSelects).toContain(key);
}
}
break;
case ReturnProcessQuestionType.Info:
case ReturnProcessQuestionType.Product:
const next = (q as any).nextQuestion;
expect(next).toBeDefined();
walk(next, visited);
break;
case ReturnProcessQuestionType.Checklist:
walk((q as any).nextQuestion, visited);
break;
case ReturnProcessQuestionType.Group:
for (const sub of q.questions) {
walk(sub.key, new Set(visited));
}
break;
default:
fail(`Unhandled type ${q}`);
}
};
walk(tolinoQuestions[0].key, new Set());
});
it('all keys are unique', () => {
const seen = new Set<string>();
for (const q of tolinoQuestions) {
expect(seen.has(q.key)).toBeFalsy();
seen.add(q.key);
if (q.type === ReturnProcessQuestionType.Group) {
for (const sub of q.questions) {
expect(seen.has(sub.key)).toBeFalsy();
seen.add(sub.key);
}
}
}
});
it('each Select option has returnInfo.returnDetails for its key', () => {
for (const q of tolinoQuestions) {
if (q.type === ReturnProcessQuestionType.Select) {
for (const opt of q.options) {
expect(opt.returnInfo).toBeDefined();
expect(opt.returnInfo!.returnDetails).toHaveProperty(q.key);
}
}
}
});
it('calculates all possible answer paths ("click dummy")', () => {
const paths: any[] = [];
// Update terminal selects to match the actual keys in the code
const terminalSelects = [
ReturnProcessQuestionKey.ItemDamaged,
ReturnProcessQuestionKey.ReturnReason,
ReturnProcessQuestionKey.ItemDefective, // Add this key as it appears to be a terminal select now
];
const traverse = (key: ReturnProcessQuestionKey, acc: any) => {
const q = questionMap[key];
if (q.type === ReturnProcessQuestionType.Select) {
for (const opt of q.options) {
const nextAcc = { ...acc, [key]: opt.value };
if (opt.nextQuestion) {
traverse(opt.nextQuestion, nextAcc);
} else {
// terminal branch
expect(terminalSelects).toContain(key);
paths.push(nextAcc);
}
}
} else if (
q.type === ReturnProcessQuestionType.Info ||
q.type === ReturnProcessQuestionType.Checklist
) {
traverse((q as any).nextQuestion, acc);
} else if (q.type === ReturnProcessQuestionType.Group) {
for (const sub of q.questions) {
traverse(sub.key, { ...acc });
}
} else {
// Product (DeliveredItem) terminal
paths.push(acc);
}
};
traverse(tolinoQuestions[0].key, {});
expect(paths.length).toBeGreaterThan(0);
for (const p of paths) {
// always have the initial question answered
expect(p).toHaveProperty(ReturnProcessQuestionKey.ItemCondition);
// Must end in one of the terminal selects or include ItemDefective with No answer
// This logic may need to be adjusted based on the exact requirements
const hasReturnReason = Object.prototype.hasOwnProperty.call(
p,
ReturnProcessQuestionKey.ReturnReason,
);
const hasItemDamaged = Object.prototype.hasOwnProperty.call(
p,
ReturnProcessQuestionKey.ItemDamaged,
);
const hasItemDefective = Object.prototype.hasOwnProperty.call(
p,
ReturnProcessQuestionKey.ItemDefective,
);
// Check if at least one of these conditions is true
expect(
hasReturnReason || hasItemDamaged || hasItemDefective,
).toBeTruthy();
}
});
});

View File

@@ -10,26 +10,6 @@ import {
YesNoAnswer,
} from './constants';
/**
* Questions for the return process of Tolino devices.
* This array defines the sequence and logic flow of questions presented to users
* when processing Tolino device returns in the system.
*
* The question flow for Tolino devices is more complex than other product types
* due to the detailed assessment needed for electronic devices:
*
* 1. Item condition assessment (original packaging or opened/damaged)
* 2. Device functionality verification (can it power on?)
* 3. Package completeness check
* 4. Detailed condition assessment (case, display, USB port)
* 5. Return reason inquiry
*
* This comprehensive flow helps accurately determine return eligibility and
* properly document the condition of returned devices for appropriate processing.
*
* Each question has a unique key, descriptive text, question type, and possible options
* with their corresponding next question in the flow.
*/
export const tolinoQuestions: ReturnProcessQuestion[] = [
{
key: ReturnProcessQuestionKey.ItemCondition,
@@ -39,13 +19,25 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
{
label: 'Originalverpackt',
value: ItemConditionAnswer.OVP,
returnInfo: { itemCondition: 'Originalverpackt' },
nextQuestion: ReturnProcessQuestionKey.ReturnReason,
returnInfo: {
itemCondition: 'Originalverpackt',
returnDetails: {
[ReturnProcessQuestionKey.ItemCondition]: ItemConditionAnswer.OVP,
},
},
},
{
label: 'Geöffnet/Beschädigt',
label: 'Geöffnet/Defekt',
value: ItemConditionAnswer.Damaged,
nextQuestion: ReturnProcessQuestionKey.DevicePower,
returnInfo: { itemCondition: 'Geöffnet/Beschädigt' },
returnInfo: {
itemCondition: 'Geöffnet/Defekt',
returnDetails: {
[ReturnProcessQuestionKey.ItemCondition]:
ItemConditionAnswer.Damaged,
},
},
},
],
},
@@ -58,12 +50,21 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.PackageCompleteGroup,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.DevicePower]: YesNoAnswer.Yes,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: ReturnProcessQuestionKey.PackageCompleteGroup,
returnInfo: { returnDetails: 'Artikel defekt' },
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.DevicePower]: YesNoAnswer.No,
},
},
},
],
},
@@ -79,12 +80,22 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
{
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.CaseCondition,
nextQuestion: ReturnProcessQuestionKey.ItemDefective,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.PackageComplete]: YesNoAnswer.Yes,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: ReturnProcessQuestionKey.PackageIncompleteInfo,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.PackageComplete]: YesNoAnswer.No,
},
},
},
],
},
@@ -97,10 +108,10 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
'2.) Ladekabel',
'3.) Quickstart-Guide',
],
nextQuestion: ReturnProcessQuestionKey.PackageIncomplete,
nextQuestion: ReturnProcessQuestionKey.PackageMissingItems,
},
{
key: ReturnProcessQuestionKey.PackageIncomplete,
key: ReturnProcessQuestionKey.PackageMissingItems,
description: 'Was fehlt?',
type: ReturnProcessQuestionType.Checklist,
options: [
@@ -120,75 +131,115 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
other: {
label: 'Sonstiges',
},
nextQuestion: ReturnProcessQuestionKey.CaseCondition,
nextQuestion: ReturnProcessQuestionKey.ItemDefective,
},
],
},
{
key: ReturnProcessQuestionKey.CaseCondition,
key: ReturnProcessQuestionKey.ItemDefective,
description: 'Hat das Gerät optische oder technische Mängel?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.CaseDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.ItemDefective]: YesNoAnswer.Yes,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: undefined, // Wird dynamisch auf Basis der vorherigen Antworten gesetzt - Siehe get-return-process-questions.helper.ts
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.ItemDefective]: YesNoAnswer.No,
},
},
},
],
},
{
key: ReturnProcessQuestionKey.CaseDamaged,
description: 'Hat das Gehäuse Mängel oder ist zerkratzt?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.DisplayCondition,
returnInfo: { itemCondition: 'Gehäuse Mängel' },
nextQuestion: ReturnProcessQuestionKey.DisplayDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.CaseDamaged]: YesNoAnswer.Yes,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: ReturnProcessQuestionKey.UsbPort,
nextQuestion: ReturnProcessQuestionKey.DisplayDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.CaseDamaged]: YesNoAnswer.No,
},
},
},
],
},
{
key: ReturnProcessQuestionKey.DisplayCondition,
key: ReturnProcessQuestionKey.DisplayDamaged,
description: 'Hat das Display Kratzer oder ist gebrochen?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.ItemDamaged,
returnInfo: { itemCondition: 'Beschädigt' },
nextQuestion: ReturnProcessQuestionKey.UsbPortDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.DisplayDamaged]: YesNoAnswer.Yes,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: ReturnProcessQuestionKey.UsbPort,
nextQuestion: ReturnProcessQuestionKey.UsbPortDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.DisplayDamaged]: YesNoAnswer.No,
},
},
},
],
},
{
key: ReturnProcessQuestionKey.ItemDamaged,
description:
'Wurde der Artikel durch unsachgemäße Handhabung oder Eigenverschulden beschädigt?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: ReturnReasonAnswer.DamagedByCustomer,
},
{
label: 'Nein',
value: ReturnReasonAnswer.ReceivedDamaged,
},
],
},
{
key: ReturnProcessQuestionKey.UsbPort,
key: ReturnProcessQuestionKey.UsbPortDamaged,
description: 'Funktioniert die USB Buchse?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: YesNoAnswer.Yes,
nextQuestion: ReturnProcessQuestionKey.ReturnReason,
nextQuestion: ReturnProcessQuestionKey.ItemDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.UsbPortDamaged]: YesNoAnswer.No,
},
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
nextQuestion: ReturnProcessQuestionKey.ItemDamaged,
returnInfo: {
returnDetails: {
[ReturnProcessQuestionKey.UsbPortDamaged]: YesNoAnswer.Yes,
},
},
},
],
},
@@ -200,10 +251,59 @@ export const tolinoQuestions: ReturnProcessQuestion[] = [
{
label: 'Gefällt nicht/Widerruf',
value: ReturnReasonAnswer.Dislike,
returnInfo: {
returnReason: 'Gefällt nicht/Widerruf',
returnDetails: {
[ReturnProcessQuestionKey.ReturnReason]: ReturnReasonAnswer.Dislike,
},
},
},
{
label: 'Fehllieferung',
value: ReturnReasonAnswer.WrongItem,
nextQuestion: ReturnProcessQuestionKey.DeliveredItem,
returnInfo: {
returnReason: 'Fehllieferung',
returnDetails: {
[ReturnProcessQuestionKey.ReturnReason]:
ReturnReasonAnswer.WrongItem,
},
},
},
],
},
{
key: ReturnProcessQuestionKey.DeliveredItem,
description: 'Welcher Artikel wurde geliefert?',
type: ReturnProcessQuestionType.Product,
},
{
key: ReturnProcessQuestionKey.ItemDamaged,
description:
'Wurde der Artikel durch unsachgemäße Handhabung oder Eigenverschulden beschädigt?',
type: ReturnProcessQuestionType.Select,
options: [
{
label: 'Ja',
value: ReturnReasonAnswer.DamagedByCustomer,
returnInfo: {
returnReason: 'Geöffnet/Defekt',
returnDetails: {
[ReturnProcessQuestionKey.ItemDamaged]:
ReturnReasonAnswer.DamagedByCustomer,
},
},
},
{
label: 'Nein',
value: ReturnReasonAnswer.ReceivedDamaged,
returnInfo: {
returnReason: 'Geöffnet/Defekt',
returnDetails: {
[ReturnProcessQuestionKey.ItemDamaged]:
ReturnReasonAnswer.ReceivedDamaged,
},
},
},
],
},

View File

@@ -32,14 +32,13 @@ export const tonDatentraegerQuestions: ReturnProcessQuestion[] = [
{
label: 'Versiegelt/Originalverpackt',
value: ItemConditionAnswer.OVP,
returnInfo: { itemCondition: 'Versiegelt/Originalverpackt' },
returnInfo: { itemCondition: 'Originalverpackt' },
nextQuestion: ReturnProcessQuestionKey.ReturnReason,
},
{
label: 'Geöffnet',
value: ItemConditionAnswer.Damaged,
nextQuestion: ReturnProcessQuestionKey.ItemDefective,
returnInfo: { itemCondition: 'Geöffnet' },
},
],
},
@@ -51,12 +50,18 @@ export const tonDatentraegerQuestions: ReturnProcessQuestion[] = [
{
label: 'Ja',
value: YesNoAnswer.Yes,
returnInfo: { itemCondition: 'Defekt' },
returnInfo: {
itemCondition: 'Geöffnet/Defekt',
returnReason: 'Geöffnet/Defekt',
},
},
{
label: 'Nein',
value: YesNoAnswer.No,
returnInfo: { itemCondition: 'Ok' },
returnInfo: {
itemCondition: 'Geöffnet/Ok',
returnReason: 'Geöffnet/Ok',
},
},
],
},

View File

@@ -1,40 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { FetchReturnDetails, FetchReturnDetailsSchema } from './schemas';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { Receipt } from './models';
/**
* Service responsible for fetching return details for a given receipt.
*/
@Injectable({ providedIn: 'root' })
export class ReturnDetailsService {
#receiptService = inject(ReceiptService);
/**
* Fetches the return details for a specific receipt.
*
* @param params - The parameters required to fetch the return details, including the receipt ID.
* @returns An observable that emits 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<Receipt> {
try {
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');
}
return res.result as Receipt;
}),
);
} catch (error) {
return throwError(() => error);
}
}
}

View File

@@ -1,67 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { EnvironmentService } from '@core/environment';
import { DomainPrinterService, Printer } from '@domain/printer';
import { PrintModalComponent, PrintModalData } from '@modal/printer';
import { UiModalService } from '@ui/modal';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class ReturnPrintReceiptsService {
#printService = inject(DomainPrinterService);
#environmentSerivce = inject(EnvironmentService);
// TODO: Refactor: CDK Dialog verwenden für neuen Printer Dialog
#uiModal = inject(UiModalService);
// TODO: Refactor: CDK Dialog verwenden für neuen Printer Dialog
/**
* Prints return receipts using the appropriate printer
*
* This method:
* 1. Retrieves available label printers
* 2. Determines if a printer is selected or if running on tablet mode
* 3. Opens a print modal dialog if no printer is selected or on tablet
* 4. Prints directly to the selected printer when available
*
* @param {number[]} receiptIds - Array of receipt IDs to print
* @returns {Promise<number[]>} Promise resolving to the same array of receipt IDs
* @throws {Error} When printing operations fail
*/
async printReturns(receiptIds: number[]) {
const printerList = await firstValueFrom(
this.#printService.getAvailableLabelPrinters(),
);
let printer: Printer | undefined = undefined;
if (Array.isArray(printerList)) {
printer = printerList.find((printer) => printer.selected === true);
}
if (!printer || this.#environmentSerivce.matchTablet()) {
await this.#uiModal
.open({
content: PrintModalComponent,
config: { showScrollbarY: false },
data: {
printImmediately: !this.#environmentSerivce.matchTablet(),
printerType: 'Label',
print: (printer) =>
this.#printService
.printReturnReceipt({
printer: printer,
receiptIds,
})
.toPromise(),
} as PrintModalData,
})
.afterClosed$.toPromise();
} else {
await firstValueFrom(
this.#printService.printReturnReceipt({
printer: printer.key,
receiptIds,
}),
);
}
return receiptIds;
}
}

View File

@@ -0,0 +1,6 @@
export * from './return-can-return.service';
export * from './return-details.service';
export * from './print-receipts.service';
export * from './return-process.service';
export * from './return-search.service';
export * from './return-task-list.service';

View File

@@ -0,0 +1,68 @@
import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest';
import { PrintReceiptsService } from './print-receipts.service';
import { Printer, PrinterType, PrintService } from '@isa/common/print';
import { OMSPrintService } from '@generated/swagger/print-api';
import { of } from 'rxjs';
describe('PrintReceiptsService', () => {
let spectator: SpectatorService<PrintReceiptsService>;
const createService = createServiceFactory({
service: PrintReceiptsService,
mocks: [OMSPrintService, PrintService],
});
let mockPrintService: jest.Mocked<PrintService>;
let mockOmsPrintService: jest.Mocked<OMSPrintService>;
beforeEach(() => {
spectator = createService();
mockPrintService = spectator.inject(PrintService);
mockOmsPrintService = spectator.inject(OMSPrintService);
});
it('should be created', () => {
expect(spectator.service).toBeTruthy();
});
describe('printReturnReceipts', () => {
it('should throw an error if no return receipt IDs are provided', async () => {
await expect(
spectator.service.printReturnReceipts({ returnReceiptIds: [] }),
).rejects.toThrow('No return receipt IDs provided');
});
it('should call the print service with the correct parameters', async () => {
const mockReturnReceiptIds = [1, 2, 3];
mockPrintService.print.mockImplementation((printerType, callback) => {
expect(printerType).toBe(PrinterType.LABEL);
const mockPrinter: Printer = {
key: 'mockPrinterKey',
value: 'Mock Printer',
selected: true,
enabled: true,
description: 'Mock printer description',
};
return callback(mockPrinter);
});
mockOmsPrintService.OMSPrintReturnReceipt.mockReturnValue(
of({ error: false }),
);
await spectator.service.printReturnReceipts({
returnReceiptIds: mockReturnReceiptIds,
});
expect(mockPrintService.print).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
);
expect(mockOmsPrintService.OMSPrintReturnReceipt).toHaveBeenCalledWith({
printer: expect.any(String),
data: mockReturnReceiptIds,
});
});
});
});

View File

@@ -0,0 +1,43 @@
import { inject, Injectable } from '@angular/core';
import { OMSPrintService } from '@generated/swagger/print-api';
import { PrinterType, PrintService } from '@isa/common/print';
import { firstValueFrom } from 'rxjs';
/**
* Service responsible for printing return receipts using the OMS print API.
*
* This service handles the communication with backend printing services and manages
* the print job lifecycle through the common print infrastructure.
*/
@Injectable({ providedIn: 'root' })
export class PrintReceiptsService {
#omsPrintService = inject(OMSPrintService);
#printService = inject(PrintService);
/**
* Prints return receipts for the provided receipt IDs.
*
* @param options - The printing options
* @param options.returnReceiptIds - Array of return receipt IDs to print
* @throws {Error} When no return receipt IDs are provided
* @returns A promise that resolves when the print job is complete
*/
async printReturnReceipts({
returnReceiptIds,
}: {
returnReceiptIds: number[];
}) {
if (returnReceiptIds.length === 0) {
throw new Error('No return receipt IDs provided');
}
return this.#printService.print(PrinterType.LABEL, (printer) => {
return firstValueFrom(
this.#omsPrintService.OMSPrintReturnReceipt({
printer: printer.key,
data: returnReceiptIds,
}),
);
});
}
}

View File

@@ -0,0 +1,145 @@
import { inject, Injectable } from '@angular/core';
import {
ReceiptService,
ReturnReceiptValuesDTO,
} from '@generated/swagger/oms-api';
import { ReturnReceiptValues, ReturnReceiptValuesSchema } from '../schemas';
import { debounceTime, firstValueFrom, map } from 'rxjs';
import { CanReturn, ReturnProcess } from '../models';
import {
allReturnProcessQuestionsAnswered,
getReturnProcessQuestions,
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isReturnProcessTypeGuard } from '../guards';
/**
* Service for determining if a return process can proceed based on
* provided process data or mapped receipt values.
*
* - Validates input using Zod schemas.
* - Handles both ReturnProcess and ReturnReceiptValues as input.
* - Calls backend API to check if return is possible.
*
* @remarks
* - Uses memoization to cache results for identical inputs.
* - Throws errors for invalid payloads or failed API calls.
*/
@Injectable({ providedIn: 'root' })
export class ReturnCanReturnService {
#receiptService = inject(ReceiptService);
/**
* Determines if a return process can proceed.
*
* @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<CanReturn | undefined>;
/**
* 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<CanReturn>;
/**
* Determines if a return can proceed, accepting either a ReturnProcess or ReturnReceiptValues.
*
* @param input - The return process or mapped receipt values.
* @returns A promise resolving to a CanReturn result or undefined if the process should continue.
* @throws Error if payload validation fails or the backend call fails.
*/
async canReturn(
input: ReturnProcess | ReturnReceiptValues,
): Promise<CanReturn | undefined> {
let data: ReturnReceiptValues | undefined = undefined;
if (isReturnProcessTypeGuard(input)) {
data = this._canReturnFromReturnProcess(input);
} else {
data = this._canReturnFromReturnReceiptValues(input);
}
if (!data) {
return undefined; // Prozess soll weitergehen, daher kein Error
}
try {
return await firstValueFrom(
this.#receiptService
.ReceiptCanReturn(data as ReturnReceiptValuesDTO)
.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
);
} catch (error) {
throw new Error(`ReceiptCanReturn failed: ${String(error)}`);
}
}
/**
* Maps a ReturnProcess to ReturnReceiptValues and validates the payload.
*
* @param returnProcess - The return process to map and validate.
* @returns The validated ReturnReceiptValues, or undefined if questions are unanswered.
* @throws Error if payload validation fails.
*/
private _canReturnFromReturnProcess(
returnProcess: ReturnProcess,
): ReturnReceiptValues | undefined {
if (!returnProcess) {
throw new Error('No return process provided');
}
const questions = getReturnProcessQuestions(returnProcess);
if (!questions) {
return undefined; // Prozess soll weitergehen, daher kein Error
}
const allQuestionsAnswered = allReturnProcessQuestionsAnswered({
questions,
answers: returnProcess.answers,
});
if (!allQuestionsAnswered) {
return undefined; // Prozess soll weitergehen, daher kein Error
}
const returnReceiptValues = returnReceiptValuesMapping(
returnProcess,
) as ReturnReceiptValues;
const parsedPayload =
ReturnReceiptValuesSchema.safeParse(returnReceiptValues);
if (!parsedPayload.success) {
throw new Error('CanReturn Payload validation failed');
}
return parsedPayload.data;
}
/**
* Validates the provided ReturnReceiptValues payload.
*
* @param returnReceiptValues - The values to validate.
* @returns The validated ReturnReceiptValues.
* @throws Error if payload validation fails.
*/
private _canReturnFromReturnReceiptValues(
returnReceiptValues: ReturnReceiptValues,
): ReturnReceiptValues | undefined {
const parsedPayload =
ReturnReceiptValuesSchema.safeParse(returnReceiptValues);
if (!parsedPayload.success) {
throw new Error('CanReturn Payload validation failed');
}
return parsedPayload.data;
}
}

View File

@@ -2,8 +2,8 @@ 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 { FetchReturnDetails } from './schemas';
import { Receipt } from './models';
import { FetchReturnDetails } from '../schemas';
import { Receipt } from '../models';
describe('ReturnDetailsService', () => {
let spectator: SpectatorService<ReturnDetailsService>;

View File

@@ -0,0 +1,76 @@
import { inject, Injectable } from '@angular/core';
import {
FetchReturnDetails,
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from '../schemas';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { Receipt, ReceiptItem } from '../models';
import { CategoryQuestions } from '../questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
@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.
*/
async canReturn({ item, category }: { item: ReceiptItem; category: string }) {
const returnReceiptValues: ReturnReceiptValues = {
quantity: item.quantity.quantity,
receiptItem: {
id: item.id,
},
category,
};
return await this.#returnCanReturnService.canReturn(returnReceiptValues);
}
/**
* Gets all available product categories that have defined question sets.
*
* @returns {KeyValue<string, string>[]} Array of key-value pairs representing available categories.
*/
availableCategories(): KeyValue<string, string>[] {
return Object.keys(CategoryQuestions).map((key) => {
return { key, value: key };
});
}
/**
* Fetches the return details for a specific receipt.
*
* @param params - The parameters required to fetch the return details, including the receipt ID.
* @returns An observable that emits 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<Receipt> {
try {
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');
}
return res.result as Receipt;
}),
);
} catch (error) {
return throwError(() => error);
}
}
}

View File

@@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import {
CanReturn,
EligibleForReturn,
EligibleForReturnState,
Receipt,
@@ -9,45 +8,38 @@ import {
ReturnProcessAnswers,
ReturnProcessQuestion,
ReturnProcessQuestionType,
} from './models';
import { CategoryQuestions, ProductCategory } from './questions';
import { KeyValue } from '@angular/common';
} from '../models';
import { ProductCategory } from '../questions';
import {
ReturnProcessChecklistAnswerSchema,
ReturnReceiptValues,
ReturnReceiptValuesSchema,
} from './schemas';
} from '../schemas';
import { logger } from '@isa/core/logging';
import {
PropertyIsEmptyError,
PropertyNullOrUndefinedError,
} from '@isa/common/data-access';
import { ReturnProcessIsNotCompleteError } from './errors/return-process';
import { ReturnProcessIsNotCompleteError } from '../errors/return-process';
import {
activeReturnProcessQuestions,
allReturnProcessQuestionsAnswered,
calculateLongestQuestionDepth,
getReturnInfo,
getReturnProcessQuestions,
isElektronischeGeraeteEligibleForReturn,
isTolinoEligibleForReturn,
isTonDatentraegerEligibleForReturn,
} from './helpers/return-process';
returnReceiptValuesMapping,
} from '../helpers/return-process';
import { isEmpty, isNil } from 'lodash';
import {
ReceiptService,
ReturnReceiptValuesDTO,
} from '@generated/swagger/oms-api';
import {
catchError,
debounceTime,
firstValueFrom,
map,
Observable,
of,
} from 'rxjs';
import { ReturnPrintReceiptsService } from './return-print-receipts.service';
import { firstValueFrom } from 'rxjs';
import { PrintReceiptsService } from './print-receipts.service';
import { z } from 'zod';
import { toSignal } from '@angular/core/rxjs-interop';
/**
* Service responsible for managing the return process workflow.
@@ -59,35 +51,7 @@ export class ReturnProcessService {
#logger = logger();
#receiptService = inject(ReceiptService);
#printReceiptsService = inject(ReturnPrintReceiptsService);
/**
* Gets all available product categories that have defined question sets.
*
* @returns {KeyValue<string, string>[]} Array of key-value pairs representing available categories.
*/
availableCategories(): KeyValue<string, string>[] {
return Object.keys(CategoryQuestions).map((key) => {
return { key, value: key };
});
}
/**
* Retrieves questions applicable to a specific return process based on product category.
*
* @param {ReturnProcess} process - The return process containing product information.
* @returns {ReturnProcessQuestion[] | undefined} Questions for the category or undefined if no matching category found.
*/
returnProcessQuestions(
process: ReturnProcess,
): ReturnProcessQuestion[] | undefined {
const category = process.productCategory;
if (category) {
return CategoryQuestions[category as keyof typeof CategoryQuestions];
}
return undefined;
}
#printReceiptsService = inject(PrintReceiptsService);
/**
* Gets active questions in the return process based on previously provided answers.
@@ -106,7 +70,7 @@ export class ReturnProcessService {
activeReturnProcessQuestions(
process: ReturnProcess,
): ReturnProcessQuestion[] | undefined {
const questions = this.returnProcessQuestions(process);
const questions = getReturnProcessQuestions(process);
const answers: ReturnProcessAnswers = process.answers || {};
if (!questions) {
@@ -130,7 +94,7 @@ export class ReturnProcessService {
returnProcessQuestionsProgress(
returnProcess: ReturnProcess,
): { answered: number; total: number } | undefined {
const questions = this.returnProcessQuestions(returnProcess);
const questions = getReturnProcessQuestions(returnProcess);
if (!questions) {
return undefined;
@@ -204,42 +168,19 @@ export class ReturnProcessService {
switch (returnProcess.productCategory) {
case ProductCategory.ElektronischeGeraete:
return isElektronischeGeraeteEligibleForReturn(returnProcess);
case ProductCategory.Software:
return isElektronischeGeraeteEligibleForReturn(
returnProcess,
questions,
);
case ProductCategory.TonDatentraeger:
return isTonDatentraegerEligibleForReturn(returnProcess);
return isTonDatentraegerEligibleForReturn(returnProcess, questions);
case ProductCategory.Tolino:
return isTolinoEligibleForReturn(returnProcess);
return isTolinoEligibleForReturn(returnProcess, questions);
}
return { state: EligibleForReturnState.Eligible };
}
canReturn$(values: ReturnReceiptValues): Observable<CanReturn | undefined> {
const parsedPayload = ReturnReceiptValuesSchema.safeParse(values);
if (!parsedPayload.success) {
this.#logger.error(
'CanReturn Payload validation failed',
parsedPayload.error,
);
return of(undefined);
}
const response$ = this.#receiptService
.ReceiptCanReturn(parsedPayload.data as ReturnReceiptValuesDTO)
.pipe(
debounceTime(50),
map((res) => res as CanReturn),
catchError((err) => {
this.#logger.error('ReceiptCanReturn failed', err);
return of<CanReturn | undefined>(undefined);
}),
);
return response$;
}
/**
* Checks if all provided questions have valid answers within the return process.
* Handles specific logic for different question types like Checklist.
@@ -254,17 +195,30 @@ export class ReturnProcessService {
returnProcess: ReturnProcess,
): boolean {
return questions.every((q) => {
if (q.type === ReturnProcessQuestionType.Checklist) {
// Validate Checklist answers: must have options selected or 'other' text filled
const answer = ReturnProcessChecklistAnswerSchema.optional().parse(
returnProcess.answers[q.key],
);
return (
(answer && answer.options?.length > 0) || (answer && !!answer.other)
);
} else {
// For other types, simply check if an answer exists for the question key
return q.key in returnProcess.answers;
switch (q.type) {
case ReturnProcessQuestionType.Checklist: {
// Validate Checklist answers: must have options selected or 'other' text filled
const answer = ReturnProcessChecklistAnswerSchema.optional().parse(
returnProcess.answers[q.key],
);
return Boolean(
answer && (answer.options?.length > 0 || answer.other),
);
}
case ReturnProcessQuestionType.Info: {
return true; // Info questions are always considered answered
}
case ReturnProcessQuestionType.Group: {
// Group: nicht q.key selbst, sondern alle Unter-Fragen prüfen
return this._areAllQuestionsAnswered(q.questions, returnProcess);
}
default: {
// For other types, simply check if an answer exists for the question key
return q.key in returnProcess.answers;
}
}
});
}
@@ -277,7 +231,7 @@ export class ReturnProcessService {
* @throws {PropertyNullOrUndefinedError} If questions cannot be found.
*/
getReturnInfo(process: ReturnProcess): ReturnInfo | undefined {
const questions = this.returnProcessQuestions(process);
const questions = getReturnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
@@ -317,7 +271,7 @@ export class ReturnProcessService {
}
const incompleteProcess = processes.find((process) => {
const questions = this.returnProcessQuestions(process);
const questions = getReturnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
@@ -344,8 +298,7 @@ export class ReturnProcessService {
});
const payload: ReturnReceiptValues[] = eligibleProcesses.map(
(process) =>
this.mapToReturnReceiptValues(process) as ReturnReceiptValues,
(process) => returnReceiptValuesMapping(process) as ReturnReceiptValues,
);
const parsedPayload = z.array(ReturnReceiptValuesSchema).safeParse(payload);
@@ -363,39 +316,10 @@ export class ReturnProcessService {
const receipts = response.result as Receipt[];
const receiptIds = receipts.map((receipt) => receipt.id);
await this.#printReceiptsService.printReturns(receiptIds);
await this.#printReceiptsService.printReturnReceipts({
returnReceiptIds: receiptIds,
});
return receipts;
}
mapToReturnReceiptValues(
process: ReturnProcess,
): ReturnReceiptValues | undefined {
const questions = this.returnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
const returnInfo = getReturnInfo({
questions,
answers: process.answers,
});
if (!returnInfo) {
throw new PropertyNullOrUndefinedError('returnInfo');
}
return {
quantity: process.receiptItem.quantity.quantity, // TODO: Teilmenge handling implementieren - Aktuell wird die gesamte Quantity genommen
comment: returnInfo.comment,
itemCondition: returnInfo.itemCondition,
otherProduct: returnInfo.otherProduct,
returnDetails: returnInfo.returnDetails,
returnReason: returnInfo.returnReason,
category: process?.receiptItem?.features?.['category'],
receiptItem: {
id: process.receiptItem.id,
},
};
}
}

View File

@@ -1,13 +1,13 @@
import { inject, Injectable } from '@angular/core';
import { QuerySettingsDTO, ReceiptService } from '@generated/swagger/oms-api';
import { map, Observable, throwError } from 'rxjs';
import { QueryTokenInput, QueryTokenSchema } from './schemas';
import { ReceiptListItem } from './models';
import { QueryTokenInput, QueryTokenSchema } from '../schemas';
import { ReceiptListItem } from '../models';
import { ListResponseArgs } from '@isa/common/data-access';
import {
ReturnParseQueryTokenError,
ReturnSearchSearchError,
} from './errors/return-search.error';
} from '../errors/return-search.error';
import { ZodError } from 'zod';
@Injectable({ providedIn: 'root' })

View File

@@ -1,9 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptItemTaskListItem, TaskActionTypeType } from './models';
import { QueryTokenInput, QueryTokenSchema } from './schemas';
import { ReceiptItemTaskListItem, TaskActionTypeType } from '../models';
import { QueryTokenInput, QueryTokenSchema } from '../schemas';
import { ZodError } from 'zod';
import { ReturnParseQueryTokenError } from './errors';
import { ReturnParseQueryTokenError } from '../errors';
import {
ReceiptService,
ResponseArgsOfReceiptItemTaskListItemDTO,

View File

@@ -0,0 +1,4 @@
export * from './return-details.store';
export * from './return-process.store';
export * from './return-search.store';
export * from './return-task-list.store';

View File

@@ -1,10 +1,10 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnDetailsStore } from './return-details.store';
import { ReturnDetailsService } from './return-details.service';
import { ReturnDetailsService } from '../services';
import { patchState } from '@ngrx/signals';
import { AsyncResultStatus } from '@isa/common/data-access';
import { addEntity } from '@ngrx/signals/entities';
import { Receipt } from './models';
import { Receipt } from '../models';
import { of, throwError } from 'rxjs';
describe('ReturnDetailsStore', () => {

View File

@@ -4,9 +4,9 @@ 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 './return-details.service';
import { ReturnDetailsService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { Receipt } from './models';
import { Receipt } from '../models';
/**
* Represents the result of a return operation, including the receipt data and status.
@@ -84,6 +84,112 @@ export const ReturnDetailsStore = signalStore(
}),
);
},
/**
* 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)) => ({
/**

View File

@@ -4,8 +4,8 @@ import { IDBStorageProvider } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';
import { patchState } from '@ngrx/signals';
import { setAllEntities } from '@ngrx/signals/entities';
import { Product, ReturnProcess } from './models';
import { CreateReturnProcessError } from './errors/return-process';
import { Product, Receipt, ReturnProcess } from '../models';
import { CreateReturnProcessError } from '../errors/return-process';
const TEST_ITEMS: Record<number, ReturnProcess['receiptItem']> = {
1: {
@@ -104,19 +104,6 @@ describe('ReturnProcessStore', () => {
store.removeAnswer(1, 'question1');
expect(store.entityMap()[1].answers['question1']).toBeUndefined();
});
it('should set a product category for a given entity', () => {
const spectator = createService();
const store = spectator.service;
patchState(
store as any,
setAllEntities([{ id: 1, processId: 1, productCategory: undefined }]),
);
store.setProductCategory(1, 'Electronics');
expect(store.entityMap()[1].productCategory).toBe('Electronics');
});
});
describe('Process Management', () => {
@@ -126,7 +113,7 @@ describe('ReturnProcessStore', () => {
store.startProcess({
processId: 1,
receiptId: 123,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[3]],
});
@@ -140,7 +127,7 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receiptId: 123,
receipt: {} as Receipt,
items: [TEST_ITEMS[2]],
});
}).toThrow(CreateReturnProcessError);
@@ -153,7 +140,7 @@ describe('ReturnProcessStore', () => {
expect(() => {
store.startProcess({
processId: 1,
receiptId: 123,
receipt: {} as Receipt,
items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]],
});
}).toThrow(CreateReturnProcessError);

View File

@@ -13,12 +13,12 @@ import {
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 { Receipt, ReceiptItem, ReturnProcess } from '../models';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
CreateReturnProcessError,
CreateReturnProcessErrorReason,
} from './errors/return-process';
} from '../errors/return-process';
/**
* Interface representing the parameters required to start a return process.
@@ -114,27 +114,6 @@ export const ReturnProcessStore = signalStore(
store.storeState();
}
},
/**
* Sets the product category for a specific entity.
* If the entity does not have an existing return question, for its product category,
* it will be set to the provided category.
* @param id - The ID of the entity to update.
* @param category - The product category to set for the entity.
*/
setProductCategory: (id: number, category: string | undefined) => {
const entity = store.entityMap()[id];
if (entity && !entity.returnReceipt) {
patchState(
store,
updateEntity({
id: entity.id,
changes: { productCategory: category },
}),
);
store.storeState();
}
},
})),
withMethods((store) => ({
/**

View File

@@ -1,9 +1,9 @@
import { createServiceFactory } from '@ngneat/spectator/jest';
import { ReturnSearchStore, ReturnSearchStatus } from './return-search.store';
import { ReturnSearchService } from './return-search.service';
import { ReturnSearchService } from '../services';
import { of, throwError } from 'rxjs';
import { ListResponseArgs } from '@isa/common/data-access';
import { ReceiptListItem } from './models';
import { ReceiptListItem } from '../models';
describe('ReturnSearchStore', () => {
const createService = createServiceFactory({

View File

@@ -14,12 +14,12 @@ import {
withEntities,
} from '@ngrx/signals/entities';
import { pipe, switchMap, tap } from 'rxjs';
import { ReturnSearchService } from './return-search.service';
import { ReturnSearchService } from '../services';
import { tapResponse } from '@ngrx/operators';
import { effect, inject } from '@angular/core';
import { QueryTokenSchema } from './schemas';
import { QueryTokenSchema } from '../schemas';
import { Callback, ListResponseArgs } from '@isa/common/data-access';
import { ReceiptListItem } from './models';
import { ReceiptListItem } from '../models';
import { Query } from '@isa/shared/filter';
import { SessionStorageProvider, withStorage } from '@isa/core/storage';
import { ProcessService } from '@isa/core/process';

View File

@@ -1,14 +1,14 @@
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { withEntities, updateEntity, addEntity } from '@ngrx/signals/entities';
import { inject } from '@angular/core';
import { ReceiptItemTaskListItem } from './models';
import { ReceiptItemTaskListItem } from '../models';
import { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReturnTaskListService } from './return-task-list.service';
import { ReturnTaskListService } from '../services';
import { pipe, switchMap, tap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { logger } from '@isa/core/logging';
import { QueryTokenInput } from './schemas';
import { QueryTokenInput } from '../schemas';
/**
* Represents the structure of a return task list item in the store.

View File

@@ -1,7 +1,51 @@
<div class="return-details-header--header">
<button uiInfoButton>
<div
class="return-details-header--header"
data-what="container"
data-which="return-details-header"
>
<button
uiInfoButton
[cdkMenuTriggerFor]="menu"
[disabled]="!referenceId()"
data-what="button"
data-which="customer-actions"
>
<ng-icon uiInfoButtonLabel name="isaNavigationKunden"></ng-icon>
<ng-icon uiInfoButtonIcon name="isaActionChevronDown"></ng-icon>
</button>
<h3 class="isa-text-subtitle-1-regular text-isa-neutral-900">{{ name() }}</h3>
<h3
class="isa-text-subtitle-1-regular text-isa-neutral-900"
data-what="heading"
data-which="customer-name"
>
{{ name() }}
</h3>
</div>
<ng-template #menu>
@let refId = referenceId();
@let n = now();
<ui-menu data-what="menu" data-which="customer-navigation">
<a
[routerLink]="['/kunde', n, 'customer', 'search', refId]"
uiMenuItem
data-what="menu-item"
data-which="customer-details"
>Kundendetails</a
>
<a
[routerLink]="['/kunde', n, 'customer', 'search', refId, 'orders']"
uiMenuItem
data-what="menu-item"
data-which="customer-orders"
>Bestellungen</a
>
<a
[routerLink]="['/kunde', n, 'customer', 'search', refId, 'history']"
uiMenuItem
data-what="menu-item"
data-which="customer-history"
>Historie</a
>
</ui-menu>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ReturnDetailsHeaderComponent } from './return-details-header.component';
import { Buyer } from '@isa/oms/data-access';
describe('ReturnDetailsHeaderComponent', () => {
let spectator: Spectator<ReturnDetailsHeaderComponent>;
const createComponent = createComponentFactory({
component: ReturnDetailsHeaderComponent,
});
let buyerMock: Buyer;
beforeEach(() => {
buyerMock = {
buyerNumber: '12345',
};
spectator = createComponent({
props: {
buyer: buyerMock,
},
});
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('[data-what="button"][data-which="customer-actions"]', () => {
it('should be disabled if referenceId() returns undefined', () => {
jest.spyOn(spectator.component, 'referenceId').mockReturnValue(undefined);
spectator.detectComponentChanges();
const button = spectator.query(
'[data-what="button"][data-which="customer-actions"]',
);
expect(button).toBeTruthy();
expect(button).toBeDisabled();
});
it('should be enabled if referenceId() returns a value', () => {
jest.spyOn(spectator.component, 'referenceId').mockReturnValue(12345);
spectator.detectComponentChanges();
const button = spectator.query(
'[data-what="button"][data-which="customer-actions"]',
);
expect(button).toBeTruthy();
expect(button).not.toBeDisabled();
});
});
});

View File

@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { isaActionChevronDown, isaNavigationKunden } from '@isa/icons';
@@ -9,6 +10,9 @@ 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';
@Component({
selector: 'oms-feature-return-details-header',
@@ -16,16 +20,32 @@ import { formatName } from 'libs/oms/utils/format-name';
styleUrls: ['./return-details-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [InfoButtonComponent, NgIconComponent],
imports: [
InfoButtonComponent,
NgIconComponent,
UiMenu,
RouterLink,
CdkMenuTrigger,
],
providers: [provideIcons({ isaNavigationKunden, isaActionChevronDown })],
})
export class ReturnDetailsHeaderComponent {
buyer = input.required<Buyer>();
referenceId = computed(() => {
const buyer = this.buyer();
return buyer?.reference?.id;
});
name = computed(() => {
console.log({ buyer: this.buyer() });
const firstName = this.buyer()?.firstName;
const lastName = this.buyer()?.lastName;
const organisationName = this.buyer()?.organisation?.name;
return formatName({ firstName, lastName, organisationName });
});
now() {
return Date.now();
}
}

View File

@@ -2,7 +2,7 @@
<ui-item-row-data-row>
<ui-item-row-data-label>Belegdatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{ (receipt().printedDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr' }}
{{ (receipt().printedDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr' }}
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
@@ -21,7 +21,7 @@
<ui-item-row-data-label>Bestelldatum:</ui-item-row-data-label>
<ui-item-row-data-value>
{{
(receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | hh:mm') + ' Uhr'
(receipt().order?.data?.orderDate | date: 'dd.MM.yyyy | HH:mm') + ' Uhr'
}}
</ui-item-row-data-value>
</ui-item-row-data-row>

View File

@@ -0,0 +1,43 @@
@if (canReturnReceiptItem()) {
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
[value]="quantity()"
(valueChange)="changeProductQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {
<ui-dropdown-option [value]="quantity">{{
quantity
}}</ui-dropdown-option>
}
</ui-dropdown>
}
<ui-dropdown
label="Produktart"
[value]="getProductCategory()"
(valueChange)="setProductCategory($event)"
>
@for (kv of availableCategories; track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
}
</ui-dropdown>
@if (selectable()) {
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[checked]="selected()"
(click)="selected.set(!selected())"
data-what="return-item-checkbox"
[attr.data-which]="item().product.ean"
/>
</ui-checkbox>
} @else if (showProductCategoryDropdownLoading()) {
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="can-return"
></ui-icon-button>
}
}

View File

@@ -0,0 +1,3 @@
:host {
@apply flex justify-center items-center gap-4;
}

View File

@@ -0,0 +1,186 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
import { CheckboxComponent } from '@isa/ui/input-controls';
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
): ReceiptItem =>
({
id: 123,
quantity: { quantity: 1 },
price: {
value: { value: 19.99, currency: 'EUR' },
vat: { inPercent: 19 },
},
product: {
ean: ean,
name: name,
contributors: 'Test Author',
format: 'HC',
formatDetail: 'Hardcover',
manufacturer: 'Test Publisher',
publicationDate: '2024-01-01T00:00:00Z',
catalogProductNumber: '1234567890',
volume: '1',
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemControlsComponent,
mocks: [ReturnDetailsService],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
overrideComponents: [
[
ReturnDetailsOrderGroupItemControlsComponent,
{
remove: { imports: [ProductImageDirective] },
add: {
imports: [MockDirective(ProductImageDirective)],
},
},
],
],
detectChanges: false, // Control initial detection manually
});
beforeEach(() => {
// Default setup with a selectable item
spectator = createComponent({
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
it('should create', () => {
// Arrange
spectator.detectChanges(); // Trigger initial render
// Assert
expect(spectator.component).toBeTruthy();
});
it('should display the checkbox when item is selectable', () => {
// Arrange
// The mock item has canReturn=true and a valid category, which should make it selectable
// after the effect executes
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
expect(checkbox).toBeTruthy();
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).toExist();
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
// Create a non-returnable item by modifying the features.category to 'unknown'
const nonReturnableItem = createMockItem('1234567890123', true);
nonReturnableItem.features = { category: 'unknown' };
// Set the item to trigger the effects which determine selectability
spectator.setInput('item', nonReturnableItem);
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(false);
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).not.toExist();
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should update the selected model when checkbox is clicked', () => {
// Arrange
spectator.detectChanges(); // This will make the component selectable via the effect
// Use the component's method directly to toggle selection
// This is similar to what happens when a checkbox is clicked
spectator.component.selected.set(!spectator.component.selected());
spectator.detectChanges();
// Assert
expect(spectator.component.selected()).toBe(true);
});
it('should reflect the initial selected state in the checkbox', () => {
// Arrange
// First ensure the item is selectable (has a non-unknown category)
const selectableItem = createMockItem(
'1234567890123',
true,
'Test Product',
'BOOK',
);
spectator.setInput('item', selectableItem);
spectator.setInput('selected', true); // Start selected
spectator.detectChanges(); // This triggers the effects that set selectable
// Assert
expect(spectator.component.selected()).toBe(true);
expect(spectator.component.selectable()).toBe(true);
// For a checkbox, we need to check that it exists
const checkbox = spectator.query(
'input[type="checkbox"][data-what="return-item-checkbox"]',
);
expect(checkbox).toExist();
// With Spectator we can use toHaveProperty for HTML elements
expect(checkbox).toHaveProperty('checked', true);
});
it('should be true when actions include canReturn with truthy value', () => {
// Arrange
const item = createMockItem('0001', true);
spectator.setInput('item', item);
// Act
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(true);
});
it('should be false when no canReturn action is present', () => {
// Arrange
const item = { ...createMockItem('0001', true), actions: [] };
spectator.setInput('item', item as any);
// Act
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
it('should be false when canReturn action has falsy value', () => {
// Arrange
const item = createMockItem('0001', false);
spectator.setInput('item', item);
// Act
spectator.detectChanges();
// Assert
expect(spectator.component.canReturnReceiptItem()).toBe(false);
});
});

View File

@@ -0,0 +1,153 @@
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { KeyValue } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
model,
output,
signal,
untracked,
} from '@angular/core';
import { logger, provideLoggerContext } from '@isa/core/logging';
import {
CanReturn,
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
CheckboxComponent,
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
@Component({
selector: 'oms-feature-return-details-order-group-item-controls',
templateUrl: './return-details-order-group-item-controls.component.html',
styleUrls: ['./return-details-order-group-item-controls.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CheckboxComponent,
IconButtonComponent,
DropdownButtonComponent,
DropdownOptionComponent,
],
providers: [
provideLoggerContext({
component: 'ReturnDetailsOrderGroupItemControlsComponent',
}),
],
})
export class ReturnDetailsOrderGroupItemControlsComponent {
item = input.required<ReceiptItem>();
receiptId = input.required<number>();
#returnDetailsService = inject(ReturnDetailsService);
#returnDetailsStore = inject(ReturnDetailsStore);
#logger = logger();
selected = model(false);
availableCategories: KeyValue<string, string>[] =
this.#returnDetailsService.availableCategories();
quantity = computed(() => {
return this.item()?.quantity.quantity;
});
quantityDropdownValues = signal<number[]>([]);
readonly showProductCategoryDropdownLoading = signal(false);
getProductCategory = computed(() => {
return this.item()?.features?.['category'] || 'unknown';
});
selectable = signal(false);
canReturn = output<CanReturn | undefined>();
canReturnReceiptItem = computed(() =>
this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
);
constructor() {
effect(() => {
const quantityDropdown = this.quantityDropdownValues();
untracked(() => {
if (quantityDropdown.length === 0) {
this.quantityDropdownValues.set(
Array.from({ length: this.quantity() }, (_, i) => i + 1),
);
}
});
});
effect(() => {
const productCategory = this.getProductCategory();
const canReturnReceiptItem = this.canReturnReceiptItem();
const isSelectable =
canReturnReceiptItem && productCategory !== 'unknown';
if (!isSelectable) {
this.selectable.set(false);
} else {
this.selectable.set(true);
}
});
}
async setProductCategory(category: string | undefined) {
const itemToUpdate = {
item: this.item(),
category: category || 'unknown',
};
try {
this.showProductCategoryDropdownLoading.set(true);
this.canReturn.emit(undefined);
this.selectable.set(false);
const canReturn =
await this.#returnDetailsService.canReturn(itemToUpdate);
this.canReturn.emit(canReturn);
this.changeProductCategory(category || 'unknown');
this.showProductCategoryDropdownLoading.set(false);
} catch (error) {
this.#logger.error('Failed to setProductCategory', error, () => ({
itemId: this.item().id,
category,
}));
this.canReturn.emit(undefined);
this.showProductCategoryDropdownLoading.set(false);
}
}
changeProductCategory(category: string) {
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
category,
});
}
changeProductQuantity(quantity: number) {
this.#returnDetailsStore.updateProductQuantityForItem({
receiptId: this.receiptId(),
itemId: this.item().id,
quantity,
});
}
}

View File

@@ -61,22 +61,18 @@
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
</div>
</div>
<div uiItemRowProdcutCheckbox class="flex justify-center items-center">
@if (selectable()) {
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[checked]="selected()"
(click)="selected.set(!selected())"
data-what="return-item-checkbox"
[attr.data-which]="i.product.ean"
/>
</ui-checkbox>
}
</div>
<oms-feature-return-details-order-group-item-controls
uiItemRowProductControls
(canReturn)="canReturn.set($event)"
[receiptId]="receiptId()"
(selectedChange)="selected.set($event)"
[selected]="selected()"
[item]="i"
>
</oms-feature-return-details-order-group-item-controls>
</ui-item-row>
@if (!selectable()) {
@if (!canReturnReceiptItem()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex items-center self-end gap-1 pb-6"
>

View File

@@ -2,18 +2,18 @@ import { byText } from '@ngneat/spectator';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockDirective } from 'ng-mocks';
import { ReceiptItem } from '@isa/oms/data-access';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { ReceiptItem, ReturnDetailsService } from '@isa/oms/data-access';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item.component';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
// Helper function to create mock ReceiptItem data
const createMockItem = (
ean: string,
canReturn: boolean,
name = 'Test Product',
category = 'BOOK', // Add default category that's not 'unknown'
): ReceiptItem =>
({
id: 123,
@@ -34,15 +34,17 @@ const createMockItem = (
volume: '1',
},
actions: [{ key: 'canReturn', value: String(canReturn) }],
features: { category: category }, // Add the features property with category
}) as ReceiptItem;
describe('ReturnDetailsOrderGroupItemComponent', () => {
let spectator: Spectator<ReturnDetailsOrderGroupItemComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockItemNotSelectable = createMockItem('9876543210987', false);
const createComponent = createComponentFactory({
component: ReturnDetailsOrderGroupItemComponent,
mocks: [ReturnDetailsService],
componentMocks: [ReturnDetailsOrderGroupItemControlsComponent],
// Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc.
// We don't need deep interaction, just verify the host component renders correctly.
// If specific interactions were needed, we could provide mocks or use overrideComponents.
@@ -66,6 +68,7 @@ describe('ReturnDetailsOrderGroupItemComponent', () => {
props: {
item: mockItemSelectable, // Use signal for input
selected: false, // Use signal for model
receiptId: 123,
},
});
});
@@ -104,94 +107,4 @@ describe('ReturnDetailsOrderGroupItemComponent', () => {
item.product.ean,
);
});
it('should display the checkbox when item is selectable', () => {
// Arrange
spectator.setInput('item', mockItemSelectable); // Ensure selectable item
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(true);
const checkbox = spectator.query(CheckboxComponent);
expect(checkbox).toBeTruthy();
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).toExist();
});
it('should NOT display the checkbox when item is not selectable', () => {
// Arrange
spectator.setInput('item', mockItemNotSelectable);
spectator.detectChanges();
// Assert
expect(spectator.component.selectable()).toBe(false);
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).not.toExist();
expect(spectator.query(CheckboxComponent)).toBeFalsy();
});
it('should update the selected model when checkbox is clicked', () => {
// Arrange
spectator.setInput('item', mockItemSelectable); // Ensure checkbox is visible
spectator.setInput('selected', false); // Start deselected
spectator.detectChanges();
const checkboxInput = spectator.query(
`input[data-what="return-item-checkbox"]`,
);
expect(checkboxInput).not.toBeChecked();
expect(spectator.component.selected()).toBe(false);
// Act
spectator.click(checkboxInput!);
// Assert
expect(spectator.component.selected()).toBe(true);
// Note: Checking the input's checked state directly after click might be flaky
// depending on change detection timing. Relying on the model signal is more robust.
});
it('should reflect the initial selected state in the checkbox', () => {
// Arrange
spectator.setInput('item', mockItemSelectable);
spectator.setInput('selected', true); // Start selected
spectator.detectChanges();
// Assert
const checkboxInput = spectator.query(
`input[data-what="return-item-checkbox"]`,
);
expect(checkboxInput).toBeChecked();
});
it('should have correct E2E attributes', () => {
// Arrange
spectator.detectChanges();
const item = mockItemSelectable;
const ean = item.product.ean;
// Assert
expect(spectator.query(`[data-what="return-item-row"]`)).toBeTruthy();
expect(spectator.query(`[data-which="${ean}"]`)).toBeTruthy();
expect(spectator.query(`img[data-what="product-image"]`)).toHaveAttribute(
'data-which',
ean,
);
expect(spectator.query(`[data-what="product-name"]`)).toHaveAttribute(
'data-which',
ean,
);
expect(spectator.query(`[data-what="product-price"]`)).toHaveAttribute(
'data-which',
ean,
);
expect(spectator.query(`[data-what="product-info"]`)).toHaveAttribute(
'data-which',
ean,
);
expect(
spectator.query(`input[data-what="return-item-checkbox"]`),
).toHaveAttribute('data-which', ean);
});
});

View File

@@ -6,18 +6,16 @@ import {
computed,
input,
model,
signal,
WritableSignal,
} from '@angular/core';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
import { ReceiptItem } from '@isa/oms/data-access';
import { CanReturn, ReceiptItem } from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component';
/**
* Component to display a single item within an order group for the return details feature.
* It shows item details like image, name, price, and allows selection if the item is returnable.
*/
@Component({
selector: 'oms-feature-return-details-order-group-item',
templateUrl: './return-details-order-group-item.component.html',
@@ -27,30 +25,61 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
imports: [
ItemRowComponent,
ProductImageDirective,
CheckboxComponent,
NgIconComponent,
DatePipe,
CurrencyPipe,
LowerCasePipe,
ReturnDetailsOrderGroupItemControlsComponent,
],
providers: [provideIcons({ ...ProductFormatIconGroup, isaActionClose })],
})
export class ReturnDetailsOrderGroupItemComponent {
/** The receipt item data to display. */
/**
* The receipt item data to display.
* Contains all information about a product including details, price, and return eligibility.
*/
item = input.required<ReceiptItem>();
/** Model representing the selection state of the item (checked or unchecked). */
/**
* The unique identifier of the receipt to which this item belongs.
* Used for making return eligibility checks against the backend API.
*/
receiptId = input.required<number>();
/**
* Two-way binding for the selection state of the item.
* Indicates whether the item is currently selected for return.
*/
selected = model(false);
/** Computed signal indicating whether the item is selectable for return based on its actions. */
selectable = computed(() => {
return this.item()?.actions?.some(
/**
* Holds the return eligibility information from the API.
* Contains both the eligibility result (boolean) and any message explaining the reason.
* This signal may be undefined if the eligibility check hasn't completed yet.
*/
canReturn: WritableSignal<CanReturn | undefined> = signal(undefined);
/**
* Computes whether the item can be returned.
* Prefers the endpoint result if available, otherwise checks the item's actions.
*/
canReturnReceiptItem = computed(() => {
const canReturn = this.canReturn()?.result;
const canReturnReceiptItem = this.item()?.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
);
return canReturn ?? canReturnReceiptItem; // Endpoint Result (if existing) overrules item result
});
/**
* Computes the message explaining the return eligibility.
* Prefers the endpoint message if available, otherwise uses the item's action description.
*/
canReturnMessage = computed(() => {
return this.item()?.actions?.find((a) => a.key === 'canReturn')
?.description;
const canReturnMessage = this.canReturn()?.message;
const canReturnMessageOnReceiptItem = this.item()?.actions?.find(
(a) => a.key === 'canReturn',
)?.description;
return canReturnMessage ?? canReturnMessageOnReceiptItem; // Endpoint Message (if existing) overrules item message
});
}

View File

@@ -26,10 +26,11 @@ export class ReturnDetailsOrderGroupComponent {
selectedItems = model<ReceiptItem[]>([]);
selectableItems = computed(() => {
return this.items().filter((item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
),
return this.items().filter(
(item) =>
item.actions?.some(
(a) => a.key === 'canReturn' && coerceBooleanProperty(a.value),
) && item?.features?.['category'] !== 'unknown',
);
});

View File

@@ -63,6 +63,7 @@
<oms-feature-return-details-order-group-item
class="border-b border-solid border-isa-neutral-300 last:border-none"
[item]="item.data"
[receiptId]="itemId()"
(selectedChange)="selectItemById(item.id, $event)"
[selected]="selectedItemIds().includes(item.id)"
></oms-feature-return-details-order-group-item>

View File

@@ -20,6 +20,7 @@ import {
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import {
ReceiptItem,
ReturnDetailsService,
ReturnDetailsStore,
ReturnProcessStore,
} from '@isa/oms/data-access';
@@ -29,7 +30,7 @@ import { ReturnDetailsHeaderComponent } from './return-details-header/return-det
import { ReturnDetailsOrderGroupComponent } from './return-details-order-group/return-details-order-group.component';
import { ReturnDetailsOrderGroupItemComponent } from './return-details-order-group-item/return-details-order-group-item.component';
import { ReturnDetailsOrderGroupDataComponent } from './return-details-order-group-data/return-details-order-group-data.component';
import { Location } from '@angular/common';
import { KeyValue, Location } from '@angular/common';
@Component({
selector: 'oms-feature-return-details',
@@ -70,9 +71,15 @@ export class ReturnDetailsComponent {
showMore = signal(false);
#returnDetailsStore = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
// Also strongly type if `availableCategories` is an array of strings
availableCategories = computed<KeyValue<string, string>[]>(() => {
return this.#returnDetailsService.availableCategories() ?? [];
});
itemId = computed<number>(() => {
const params = this.params();
if (params) {
@@ -104,6 +111,34 @@ export class ReturnDetailsComponent {
});
}
changeProductCategory({
item,
category,
}: {
item: ReceiptItem;
category: string;
}) {
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.itemId(),
itemId: item.id,
category,
});
}
changeProductQuantity({
item,
quantity,
}: {
item: ReceiptItem;
quantity: number;
}) {
this.#returnDetailsStore.updateProductQuantityForItem({
receiptId: this.itemId(),
itemId: item.id,
quantity,
});
}
selectItemById(id: number, selected: boolean) {
const items = this.receiptItems();
const item = items.find((i) => i.id === id);

View File

@@ -42,5 +42,14 @@
</div>
}
}
} @else if (canReturnReceipt === undefined && eligible) {
<div class="w-full flex items-center justify-end">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="can-return"
></ui-icon-button>
</div>
}
}

View File

@@ -2,24 +2,27 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
untracked,
WritableSignal,
} from '@angular/core';
import {
CanReturn,
EligibleForReturnState,
ReturnCanReturnService,
ReturnProcessService,
ReturnProcessStore,
ReturnReceiptValues,
} from '@isa/oms/data-access';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionCheck, isaActionClose } from '@isa/icons';
import { ReturnProcessQuestionsComponent } from '../return-process-questions/return-process-questions.component';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, filter, of, startWith, switchMap } from 'rxjs';
import { allReturnProcessQuestionsAnswered } from 'libs/oms/data-access/src/lib/helpers/return-process';
import { logger, provideLoggerContext } from '@isa/core/logging';
import { IconButtonComponent } from '@isa/ui/buttons';
/**
* Component that displays a single return process item with its associated questions and progress.
@@ -42,15 +45,23 @@ import { allReturnProcessQuestionsAnswered } from 'libs/oms/data-access/src/lib/
NgIconComponent,
ReturnProcessQuestionsComponent,
ProgressBarComponent,
IconButtonComponent,
ReturnProductInfoComponent,
],
providers: [provideIcons({ isaActionCheck, isaActionClose })],
providers: [
provideIcons({ isaActionCheck, isaActionClose }),
provideLoggerContext({ component: 'ReturnProcessItemComponent' }),
],
})
export class ReturnProcessItemComponent {
EligibleForReturnState = EligibleForReturnState;
#returnProcessService = inject(ReturnProcessService);
#returnCanReturnService = inject(ReturnCanReturnService);
returnProcessStore = inject(ReturnProcessStore);
/** Logger instance specific to this component context. */
#logger = logger();
/** The unique identifier for the return process */
returnProcessId = input.required<number>();
@@ -66,39 +77,17 @@ export class ReturnProcessItemComponent {
);
});
// TODO: canReturn Aufruf in den Service Auslagern
private readonly canReturn$ = toObservable(this.returnProcess).pipe(
filter((rp): rp is NonNullable<typeof rp> => !!rp),
debounceTime(50),
switchMap((rp) => {
const questions = this.#returnProcessService.returnProcessQuestions(rp);
if (!questions) {
return of(undefined);
}
const allQuestionsAnswered = allReturnProcessQuestionsAnswered({
questions,
answers: rp.answers,
});
if (!allQuestionsAnswered) {
return of(undefined);
}
return this.#returnProcessService
.canReturn$(
this.#returnProcessService.mapToReturnReceiptValues(
rp,
) as ReturnReceiptValues,
)
.pipe(startWith<CanReturn | undefined>(undefined));
}),
);
canReturn = toSignal<CanReturn | undefined>(this.canReturn$, {
initialValue: undefined,
});
/**
* Signal holding the backend validation result for whether the current return process
* can be returned. Initially undefined, it is asynchronously set after backend validation.
*
* @type {WritableSignal<CanReturn | undefined>}
* @see ReturnCanReturnService.canReturn
* @remarks
* - This signal is reset to undefined before each backend check.
* - The value is updated with the backend response for the current return process.
*/
canReturn: WritableSignal<CanReturn | undefined> = signal(undefined);
/**
* Signal that evaluates if the current return process is eligible for return
@@ -143,4 +132,44 @@ export class ReturnProcessItemComponent {
return canReturnMessage ?? eligibleForReturnReason; // Fallback to eligibleForReturnReason if canReturnMessage is not available
});
/**
* Initializes the component and sets up a reactive effect to validate
* the return process eligibility with the backend whenever the process changes.
*
* - Resets the `canReturn` signal to `undefined` before each backend check.
* - Asynchronously updates `canReturn` with the backend response.
* - Ensures backend validation is always in sync with the current return process.
*
* @remarks
* This approach leverages Angular signals and effects for local state management,
* following workspace guidelines for clean, reactive, and maintainable code.
*/
constructor() {
effect(() => {
const returnProcess = this.returnProcess();
untracked(async () => {
if (returnProcess) {
this.canReturn.set(undefined);
try {
const canReturnResponse =
await this.#returnCanReturnService.canReturn(returnProcess);
this.canReturn.set(canReturnResponse);
} catch (error) {
this.#logger.error(
'Failed to validate return process',
error,
() => ({
returnProcessId: returnProcess.id,
}),
);
this.canReturn.set(undefined);
}
} else {
this.canReturn.set(undefined);
}
});
});
}
}

View File

@@ -1,21 +1,3 @@
@if (showProductCategoryDropdown()) {
<div
class="flex flex-row gap-2 w-full justify-between items-center border-b border-isa-neutral-300 pb-6 last:border-b-0"
>
<div class="isa-text-body-1-regular">
Um welches Produkt handelt es sich?
</div>
<ui-dropdown
label="Produktart auswählen"
[value]="productCategoryDropdown()"
(valueChange)="setProductCategory($event)"
>
@for (kv of availableCategories(); track kv.key) {
<ui-dropdown-option [value]="kv.key">{{ kv.value }}</ui-dropdown-option>
}
</ui-dropdown>
</div>
}
<oms-feature-return-process-questions-renderer
[returnProcessId]="returnProcessId()"
[questions]="questions()"

View File

@@ -1,4 +1,3 @@
import { KeyValue } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@@ -13,10 +12,6 @@ import {
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
import { ReturnProcessQuestionsRendererComponent } from './return-process-questions-renderer/return-process-questions-renderer';
/**
@@ -39,11 +34,7 @@ import { ReturnProcessQuestionsRendererComponent } from './return-process-questi
templateUrl: './return-process-questions.component.html',
styleUrls: ['./return-process-questions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnProcessQuestionsRendererComponent,
DropdownButtonComponent,
DropdownOptionComponent,
],
imports: [ReturnProcessQuestionsRendererComponent],
})
export class ReturnProcessQuestionsComponent {
#returnProcessStore = inject(ReturnProcessStore);
@@ -79,45 +70,4 @@ export class ReturnProcessQuestionsComponent {
[]
);
});
// Also strongly type if `availableCategories` is an array of strings
availableCategories = computed<KeyValue<string, string>[]>(() => {
return this.#returnProcessService.availableCategories() ?? [];
});
setProductCategory(category: string | undefined) {
this.#returnProcessStore.setProductCategory(
this.returnProcessId(),
category,
);
}
productCategoryDropdown = computed<string | undefined>(() => {
const currentProcess = this.returnProcess();
if (!currentProcess) {
return undefined;
}
return (
currentProcess.productCategory ||
currentProcess.receiptItem.features?.['category']
);
});
// Return false or display a fallback UI if no returnProcess is found
showProductCategoryDropdown = computed<boolean>(() => {
const currentProcess = this.returnProcess();
if (
!currentProcess?.productCategory ||
currentProcess.productCategory === 'unknown'
) {
return true;
}
if (!currentProcess) {
return false;
}
const currentQuestions = this.questions();
return !currentQuestions.length;
});
}

View File

@@ -1,4 +1,4 @@
<div>
<div class="flex-1">
{{ question().description }}
</div>
<ui-chips [formControl]="control">

View File

@@ -24,7 +24,7 @@
}
}
@if (canContinueToSummary()) {
@if (canContinueToSummary() && canReturn()) {
<div class="text-right">
<a
data-what="summary-navigation"

View File

@@ -1,283 +1,308 @@
// import { signal } from '@angular/core';
// import { Location } from '@angular/common';
// import { RouterLink } from '@angular/router';
// import {
// createRoutingFactory,
// mockProvider,
// SpectatorRouting,
// } from '@ngneat/spectator/jest';
// import { MockComponent } from 'ng-mocks';
// import {
// ReturnProcess,
// ReturnProcessService,
// ReturnProcessStore,
// } from '@isa/oms/data-access';
import { signal } from '@angular/core';
import { Location } from '@angular/common';
import { RouterLink } from '@angular/router';
import {
createRoutingFactory,
mockProvider,
SpectatorRouting,
} from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import {
ReturnCanReturnService,
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
// import { ButtonComponent } from '@isa/ui/buttons';
// import { NgIconComponent } from '@ng-icons/core';
// import { ReturnProcessItemComponent } from './return-process-item/return-process-item.component';
// import { ReturnProcessComponent } from './return-process.component';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent } from '@ng-icons/core';
import { ReturnProcessItemComponent } from './return-process-item/return-process-item.component';
import { ReturnProcessComponent } from './return-process.component';
// const mockActivatedProcessIdSignal = signal<number | null>(123);
// jest.mock('@isa/core/process', () => ({
// injectActivatedProcessId: jest.fn(() => mockActivatedProcessIdSignal),
// }));
const mockActivatedProcessIdSignal = signal<number | null>(123);
jest.mock('@isa/core/process', () => ({
injectActivatedProcessId: jest.fn(() => mockActivatedProcessIdSignal),
}));
// describe('ReturnProcessComponent', () => {
// let spectator: SpectatorRouting<ReturnProcessComponent>;
// const mockEntitiesSignal = signal<ReturnProcess[]>([]);
// let mockReturnProcessService: jest.Mocked<ReturnProcessService>;
describe('ReturnProcessComponent', () => {
let spectator: SpectatorRouting<ReturnProcessComponent>;
const mockEntitiesSignal = signal<ReturnProcess[]>([]);
let mockReturnProcessService: jest.Mocked<ReturnProcessService>;
const createComponent = createRoutingFactory({
component: ReturnProcessComponent,
// Import necessary modules/components used directly in the template
imports: [RouterLink],
// Mock child components and directives used in the template
declarations: [
MockComponent(ButtonComponent),
MockComponent(NgIconComponent),
MockComponent(ReturnProcessItemComponent),
],
providers: [
// Mock ReturnProcessStore and its entities signal
mockProvider(ReturnProcessStore, {
entities: mockEntitiesSignal,
}),
mockProvider(ReturnProcessService),
mockProvider(ReturnCanReturnService, {
canReturn: jest.fn().mockResolvedValue({ result: true }),
}),
],
// Disable initial change detection to set signals before first render
detectChanges: false,
});
beforeEach(() => {
// Reset signals before each test
mockEntitiesSignal.set([]);
mockActivatedProcessIdSignal.set(null); // Mock process ID
// Create the component instance
spectator = createComponent();
// const createComponent = createRoutingFactory({
// component: ReturnProcessComponent,
// // Import necessary modules/components used directly in the template
// imports: [RouterLink],
// // Mock child components and directives used in the template
// declarations: [
// MockComponent(ButtonComponent),
// MockComponent(NgIconComponent),
// MockComponent(ReturnProcessItemComponent),
// ],
// providers: [
// // Mock ReturnProcessStore and its entities signal
// mockProvider(ReturnProcessStore, {
// entities: mockEntitiesSignal,
// }),
// mockProvider(ReturnProcessService),
// ],
// // Disable initial change detection to set signals before first render
// detectChanges: false,
// });
// Get the mocked service
mockReturnProcessService = spectator.inject(
ReturnProcessService,
) as jest.Mocked<ReturnProcessService>;
// Default mock implementation for returnProcessQuestionsProgress
mockReturnProcessService.returnProcessQuestionsProgress = jest
.fn()
.mockReturnValue({ answered: 0, total: 0 });
// beforeEach(() => {
// // Reset signals before each test
// mockEntitiesSignal.set([]);
// mockActivatedProcessIdSignal.set(null); // Mock process ID
// // Create the component instance
// spectator = createComponent();
// Default mock implementation for eligibleForReturn
mockReturnProcessService.eligibleForReturn = jest
.fn()
.mockReturnValue({ state: 'not-eligible' });
});
// // Get the mocked service
// mockReturnProcessService = spectator.inject(
// ReturnProcessService,
// ) as jest.Mocked<ReturnProcessService>;
// // Default mock implementation for returnProcessQuestionsProgress
// mockReturnProcessService.returnProcessQuestionsProgress = jest
// .fn()
// .mockReturnValue({ answered: 0, total: 0 });
// });
it('should create the component', () => {
// Arrange
mockActivatedProcessIdSignal.set(123); // Set a mock process ID
spectator.detectChanges(); // Trigger initial render
// it('should create the component', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123); // Set a mock process ID
// spectator.detectChanges(); // Trigger initial render
// Assert
expect(spectator.component).toBeTruthy();
expect(spectator.component.processId()).toBe(123); // Check injected process ID
});
// // Assert
// expect(spectator.component).toBeTruthy();
// expect(spectator.component.processId()).toBe(123); // Check injected process ID
// });
it('should filter return processes by the current process ID', () => {
// Arrange
const processId = 123;
mockActivatedProcessIdSignal.set(processId);
const mockProcesses = [
{ processId: 123, id: 1 },
{ processId: 456, id: 2 },
{ processId: 123, id: 3 },
] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// it('should filter return processes by the current process ID', () => {
// // Arrange
// const processId = 123;
// mockActivatedProcessIdSignal.set(processId);
// const mockProcesses = [
// { processId: 123, id: 1 },
// { processId: 456, id: 2 },
// { processId: 123, id: 3 },
// ] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
// Act
spectator.detectChanges();
// // Act
// spectator.detectChanges();
// Assert
const filteredProcesses = spectator.component.returnProcesses();
expect(filteredProcesses.length).toBe(2);
expect(filteredProcesses.every((p) => p.processId === processId)).toBe(
true,
);
});
// // Assert
// const filteredProcesses = spectator.component.returnProcesses();
// expect(filteredProcesses.length).toBe(2);
// expect(filteredProcesses.every((p) => p.processId === processId)).toBe(
// true,
// );
// });
it('should return empty array when no process ID is available', () => {
// Arrange
mockActivatedProcessIdSignal.set(null);
const mockProcesses = [
{ processId: 123, id: 1 },
{ processId: 456, id: 2 },
] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// it('should return empty array when no process ID is available', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(null);
// const mockProcesses = [
// { processId: 123, id: 1 },
// { processId: 456, id: 2 },
// ] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
// Act
spectator.detectChanges();
// // Act
// spectator.detectChanges();
// Assert
expect(spectator.component.returnProcesses()).toEqual([]);
});
// // Assert
// expect(spectator.component.returnProcesses()).toEqual([]);
// });
it('should return empty array when entities is empty', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
mockEntitiesSignal.set([]);
// it('should return empty array when entities is empty', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// mockEntitiesSignal.set([]);
// Act
spectator.detectChanges();
// // Act
// spectator.detectChanges();
// Assert
expect(spectator.component.returnProcesses()).toEqual([]);
});
// // Assert
// expect(spectator.component.returnProcesses()).toEqual([]);
// });
it('should render ReturnProcessItemComponent for each return process', () => {
// Arrange
const processId = 123;
mockActivatedProcessIdSignal.set(processId);
const mockProcesses = [
{ processId: 123, id: 1 },
{ processId: 123, id: 2 },
] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// it('should render ReturnProcessItemComponent for each return process', () => {
// // Arrange
// const processId = 123;
// mockActivatedProcessIdSignal.set(processId);
// const mockProcesses = [
// { processId: 123, id: 1 },
// { processId: 123, id: 2 },
// ] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
// Act
spectator.detectChanges();
// // Act
// spectator.detectChanges();
// Assert
const returnProcessItems = spectator.queryAll(ReturnProcessItemComponent);
expect(returnProcessItems.length).toBe(2);
});
// // Assert
// const returnProcessItems = spectator.queryAll(ReturnProcessItemComponent);
// expect(returnProcessItems.length).toBe(2);
// });
it('should handle back navigation when Location service is used', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
spectator.detectChanges();
const location = spectator.inject(Location);
jest.spyOn(location, 'back');
// it('should handle back navigation when Location service is used', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// spectator.detectChanges();
// const location = spectator.inject(Location);
// jest.spyOn(location, 'back');
// Act
const backButton = spectator.query('button[data-what="back-navigation"]');
if (backButton) {
spectator.click(backButton); // Simulate button click
}
// // Act
// const backButton = spectator.query('button[data-what="back-navigation"]');
// if (backButton) {
// spectator.click(backButton); // Simulate button click
// }
// Assert
expect(location.back).toHaveBeenCalled();
});
// // Assert
// expect(location.back).toHaveBeenCalled();
// });
describe('summary navigation', () => {
it('should have canContinueToSummary as false when no return processes exist', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
mockEntitiesSignal.set([]);
// describe('summary navigation', () => {
// it('should have canContinueToSummary as false when no return processes exist', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// mockEntitiesSignal.set([]);
// Act
spectator.detectChanges();
// // Act
// spectator.detectChanges();
// Assert
expect(spectator.component.canContinueToSummary()).toBe(false);
});
// // Assert
// expect(spectator.component.canContinueToSummary()).toBe(false);
// });
it('should have canContinueToSummary as true when all processes have completed all questions', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
const mockProcesses = [
{ processId: 123, id: 1 },
{ processId: 123, id: 2 },
] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// it('should have canContinueToSummary as true when all processes have completed all questions', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// const mockProcesses = [
// { processId: 123, id: 1 },
// { processId: 123, id: 2 },
// ] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
// Mock all processes have answered all questions
mockReturnProcessService.returnProcessQuestionsProgress.mockImplementation(
() => ({ answered: 3, total: 3 }),
);
// // Mock all processes have answered all questions
// mockReturnProcessService.returnProcessQuestionsProgress.mockImplementation(
// () => ({ answered: 3, total: 3 }),
// );
// Mock eligibleForReturn to return Eligible state
mockReturnProcessService.eligibleForReturn.mockReturnValue({
state: 'eligible', // Use correct enum value
});
// // Act
// spectator.detectChanges();
// Act
spectator.detectChanges();
// // Assert
// expect(spectator.component.canContinueToSummary()).toBe(true);
// });
// Assert
expect(spectator.component.canContinueToSummary()).toBe(true);
});
// it('should have canContinueToSummary as false when any process has incomplete questions', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// const mockProcesses = [
// { processId: 123, id: 1 },
// { processId: 123, id: 2 },
// ] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
it('should have canContinueToSummary as false when any process has incomplete questions', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
const mockProcesses = [
{ processId: 123, id: 1 },
{ processId: 123, id: 2 },
] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// // Mock the first process has complete questions, but the second has incomplete
// mockReturnProcessService.returnProcessQuestionsProgress
// .mockImplementationOnce(() => ({ answered: 3, total: 3 }))
// .mockImplementationOnce(() => ({ answered: 2, total: 3 }));
// Mock the first process has complete questions, but the second has incomplete
mockReturnProcessService.returnProcessQuestionsProgress
.mockImplementationOnce(() => ({ answered: 3, total: 3 }))
.mockImplementationOnce(() => ({ answered: 2, total: 3 }));
// // Act
// spectator.detectChanges();
// Act
spectator.detectChanges();
// // Assert
// expect(spectator.component.canContinueToSummary()).toBe(false);
// });
// Assert
expect(spectator.component.canContinueToSummary()).toBe(false);
});
// it('should have canContinueToSummary as false when returnProcessQuestionsProgress returns null', () => {
// // Arrange
// mockActivatedProcessIdSignal.set(123);
// const mockProcesses = [{ processId: 123, id: 1 }] as ReturnProcess[];
// mockEntitiesSignal.set(mockProcesses);
it('should have canContinueToSummary as false when returnProcessQuestionsProgress returns null', () => {
// Arrange
mockActivatedProcessIdSignal.set(123);
const mockProcesses = [{ processId: 123, id: 1 }] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// // Mock the process has null progress
// mockReturnProcessService.returnProcessQuestionsProgress.mockReturnValue(
// undefined,
// );
// Mock the process has null progress
mockReturnProcessService.returnProcessQuestionsProgress.mockReturnValue(
undefined,
);
// // Act
// spectator.detectChanges();
// Act
spectator.detectChanges();
// // Assert
// expect(spectator.component.canContinueToSummary()).toBe(false);
// });
// Assert
expect(spectator.component.canContinueToSummary()).toBe(false);
});
// it('should render "Continue to summary" button', () => {
// // Arrange
// jest
// .spyOn(spectator.component, 'canContinueToSummary')
// .mockReturnValue(true);
// spectator.detectChanges();
// Skip this test since it requires complex mocking of signals
it.skip('should render "Continue to summary" button', () => {
// Arrange
// Create a mock process for testing
mockActivatedProcessIdSignal.set(123);
const mockProcesses = [{ processId: 123, id: 1 }] as ReturnProcess[];
mockEntitiesSignal.set(mockProcesses);
// // Act & Assert
// const summaryButton = spectator.query(
// 'a[data-what="summary-navigation"]',
// );
// expect(summaryButton).toBeTruthy();
// });
// Mock canContinueToSummary computed signal to return true
jest
.spyOn(spectator.component, 'canContinueToSummary')
.mockReturnValue(true);
// it('should not render "Continue to summary" button when canContinueToSummary is false', () => {
// // Arrange
// jest
// .spyOn(spectator.component, 'canContinueToSummary')
// .mockReturnValue(false);
// Need a better way to mock canReturn signal
// This is challenging because canReturn is updated in the constructor effect
// which runs immediately when the component is created
// // Act
// spectator.detectChanges();
// Act
spectator.detectChanges();
// // Assert
// const summaryButton = spectator.query('[data-what="summary-navigation"]');
// expect(summaryButton).toBeFalsy();
// });
// Assert
const summaryButton = spectator.query(
'a[data-what="summary-navigation"]',
);
// // it('should navigate to summary route when "Continue to summary" button is clicked and enabled', async () => {
// // // Arrange
expect(summaryButton).toBeTruthy();
});
// // jest
// // .spyOn(spectator.component, 'canContinueToSummary')
// // .mockReturnValue(true);
// // spectator.detectChanges();
// // expect(spectator.inject(Location).path()).toBe('/');
// // await spectator.fixture.whenStable();
// // // Get the router link directive
// // spectator.click('a[data-what="summary-navigation"]');
it('should not render "Continue to summary" button when canContinueToSummary is false', () => {
// Arrange
jest
.spyOn(spectator.component, 'canContinueToSummary')
.mockReturnValue(false);
// // await spectator.fixture.whenStable();
// Act
spectator.detectChanges();
// // // Act & Assert
// // expect(spectator.inject(Location).path()).toBe('/summary');
// // });
// });
// });
// Assert
const summaryButton = spectator.query('[data-what="summary-navigation"]');
expect(summaryButton).toBeFalsy();
});
// it('should navigate to summary route when "Continue to summary" button is clicked and enabled', async () => {
// // Arrange
// jest
// .spyOn(spectator.component, 'canContinueToSummary')
// .mockReturnValue(true);
// spectator.detectChanges();
// expect(spectator.inject(Location).path()).toBe('/');
// await spectator.fixture.whenStable();
// // Get the router link directive
// spectator.click('a[data-what="summary-navigation"]');
//
// await spectator.fixture.whenStable();
//
// // Act & Assert
// expect(spectator.inject(Location).path()).toBe('/summary');
// });
});
});

View File

@@ -2,13 +2,18 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
untracked,
WritableSignal,
} from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import {
EligibleForReturnState,
ReturnCanReturnService,
ReturnProcess,
ReturnProcessService,
ReturnProcessStore,
@@ -55,6 +60,8 @@ export class ReturnProcessComponent {
/** Signal emitting the numeric ID of the currently active return process, derived from the route parameters. Null if no ID is present. */
processId = injectActivatedProcessId();
#returnCanReturnService = inject(ReturnCanReturnService);
/**
* Computed signal that filters return processes from the store
* based on the current `processId`. Returns an empty array if
@@ -111,4 +118,68 @@ export class ReturnProcessComponent {
);
});
});
/**
* Signal indicating whether all return processes can be returned.
* Updated asynchronously based on the result of canReturn checks for each process.
*/
canReturn: WritableSignal<boolean> = signal(false);
/**
* Initializes the component and sets up an effect to determine if all return processes
* are eligible for return. The effect observes changes to `returnProcesses` and updates
* the `canReturn` signal accordingly.
*
* The logic:
* - Resets `canReturn` to undefined on each effect run.
* - For each process, asynchronously checks if it can be returned using `ReturnCanReturnService`.
* - If all processes return a positive result (`result === true`), sets `canReturn` to true.
* - Handles empty or missing process lists gracefully.
*
* @remarks
* - Uses `untracked` to avoid tracking the async block in the effect's dependency graph.
* - Ensures type safety and avoids unnecessary state updates.
* - Logs errors if the canReturn check fails for any process.
*/
constructor() {
effect(() => {
const processes = this.returnProcesses();
this.canReturn.set(false);
// Avoid tracking the async block in the effect's dependency graph
untracked(async () => {
if (!Array.isArray(processes) || processes.length === 0) {
return;
}
const canReturnResults: boolean[] = [];
for (const returnProcess of processes) {
try {
const canReturnResponse =
await this.#returnCanReturnService.canReturn(returnProcess);
canReturnResults.push(Boolean(canReturnResponse?.result));
} catch (error) {
this.#logger.error(
'Failed to check canReturn for process',
error,
() => ({
processId: returnProcess.processId,
}),
);
canReturnResults.push(false);
}
}
if (
canReturnResults.length === processes.length &&
canReturnResults.every(Boolean)
) {
this.canReturn.set(true);
} else {
this.canReturn.set(false);
}
});
});
}
}

Some files were not shown because too many files have changed in this diff Show More