mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' into release/4.0
This commit is contained in:
32
.github/instructions/nx.instructions.md
vendored
Normal file
32
.github/instructions/nx.instructions.md
vendored
Normal 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
3
.gitignore
vendored
@@ -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
15
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
7
libs/common/print/README.md
Normal file
7
libs/common/print/README.md
Normal 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.
|
||||
34
libs/common/print/eslint.config.mjs
Normal file
34
libs/common/print/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
21
libs/common/print/jest.config.ts
Normal file
21
libs/common/print/jest.config.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
20
libs/common/print/project.json
Normal file
20
libs/common/print/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
libs/common/print/src/index.ts
Normal file
2
libs/common/print/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/models';
|
||||
export * from './lib/services';
|
||||
2
libs/common/print/src/lib/models/index.ts
Normal file
2
libs/common/print/src/lib/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './printer.model';
|
||||
export * from './printer-type.enum';
|
||||
15
libs/common/print/src/lib/models/printer-type.enum.ts
Normal file
15
libs/common/print/src/lib/models/printer-type.enum.ts
Normal 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];
|
||||
16
libs/common/print/src/lib/models/printer.model.ts
Normal file
16
libs/common/print/src/lib/models/printer.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
127
libs/common/print/src/lib/print-dialog/print-dialog.component.ts
Normal file
127
libs/common/print/src/lib/print-dialog/print-dialog.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/common/print/src/lib/services/index.ts
Normal file
1
libs/common/print/src/lib/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './print.service';
|
||||
279
libs/common/print/src/lib/services/print.service.spec.ts
Normal file
279
libs/common/print/src/lib/services/print.service.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
100
libs/common/print/src/lib/services/print.service.ts
Normal file
100
libs/common/print/src/lib/services/print.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
6
libs/common/print/src/test-setup.ts
Normal file
6
libs/common/print/src/test-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
28
libs/common/print/tsconfig.json
Normal file
28
libs/common/print/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
libs/common/print/tsconfig.lib.json
Normal file
17
libs/common/print/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
16
libs/common/print/tsconfig.spec.json
Normal file
16
libs/common/print/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
0
libs/core/process/README-location-strategy.md
Normal file
0
libs/core/process/README-location-strategy.md
Normal file
0
libs/core/process/src/lib/process.store.ts
Normal file
0
libs/core/process/src/lib/process.store.ts
Normal 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';
|
||||
|
||||
@@ -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[],
|
||||
};
|
||||
|
||||
|
||||
1
libs/oms/data-access/src/lib/guards/index.ts
Normal file
1
libs/oms/data-access/src/lib/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './is-return-process-type.guard';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 } {
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('internalCalculateLongestQuestionDepth', () => {
|
||||
[ReturnProcessQuestionKey.ItemCondition]: ItemConditionAnswer.Damaged,
|
||||
};
|
||||
|
||||
const expectedDepth = 8;
|
||||
const expectedDepth = 9;
|
||||
|
||||
const result = helpers.calculateLongestQuestionDepth(
|
||||
tolinoQuestions,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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" question’s "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 customer’s 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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,5 +26,4 @@ export const CategoryQuestions: Record<
|
||||
[ProductCategory.SonstigesNonbook]: nonbookQuestions,
|
||||
[ProductCategory.ElektronischeGeraete]: elektronischeGeraeteQuestions,
|
||||
[ProductCategory.Tolino]: tolinoQuestions,
|
||||
[ProductCategory.Software]: tonDatentraegerQuestions,
|
||||
};
|
||||
|
||||
201
libs/oms/data-access/src/lib/questions/tolino.spec.ts
Normal file
201
libs/oms/data-access/src/lib/questions/tolino.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
6
libs/oms/data-access/src/lib/services/index.ts
Normal file
6
libs/oms/data-access/src/lib/services/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -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,
|
||||
4
libs/oms/data-access/src/lib/stores/index.ts
Normal file
4
libs/oms/data-access/src/lib/stores/index.ts
Normal 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';
|
||||
@@ -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', () => {
|
||||
@@ -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)) => ({
|
||||
/**
|
||||
@@ -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);
|
||||
@@ -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) => ({
|
||||
/**
|
||||
@@ -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({
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex justify-center items-center gap-4;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div>
|
||||
<div class="flex-1">
|
||||
{{ question().description }}
|
||||
</div>
|
||||
<ui-chips [formControl]="control">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@if (canContinueToSummary()) {
|
||||
@if (canContinueToSummary() && canReturn()) {
|
||||
<div class="text-right">
|
||||
<a
|
||||
data-what="summary-navigation"
|
||||
|
||||
@@ -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');
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user