feat(oms-data-access, oms-return-details, oms-return-process): improve canReturn logic and UX for return eligibility

- Refactored `ReturnCanReturnService` to use a type guard for input discrimination and improved error handling, removing logger side effects for stricter error propagation.
- Updated `ReturnDetailsService` to delegate canReturn checks to `ReturnCanReturnService` for category-based eligibility, ensuring type safety and code reuse.
- Enhanced `ReturnDetailsOrderGroupItemComponent`:
  - Added spinner feedback when canReturn is loading.
  - Used endpoint result for eligibility and message display, falling back to item actions if necessary.
  - Improved state management for dropdowns and selection, with robust error logging.
- Updated `ReturnDetailsOrderGroupComponent` to only allow selection of items with known categories and eligible for return.
- Improved `ReturnProcessItemComponent`:
  - Added loading spinner for canReturn backend checks.
  - Used endpoint result for eligibility and messaging.
  - Added robust error logging and effect-based async state management.
- Updated `ReturnProcessComponent` to check canReturn for all processes asynchronously, with error handling and correct signal updates.
- Improved templates to show loading indicators and correct eligibility messages based on backend and frontend checks.

Ref: #5088
This commit is contained in:
Nino
2025-05-16 15:18:45 +02:00
parent 8077fe949f
commit 874453f74f
11 changed files with 362 additions and 108 deletions

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import {
ReceiptService,
ReturnReceiptValuesDTO,
} from '@generated/swagger/oms-api';
import { logger } from '@isa/core/logging';
import { ReturnReceiptValues, ReturnReceiptValuesSchema } from './schemas';
import { debounceTime, firstValueFrom, map } from 'rxjs';
import { CanReturn, ReturnProcess } from './models';
@@ -13,25 +12,94 @@ import {
returnReceiptValuesMapping,
} from './helpers/return-process';
import { memorize } from '@utils/common';
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 {
#logger = logger();
#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.
*/
@memorize()
async canReturn(
returnProcess: ReturnProcess,
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) {
return undefined;
throw new Error('No return process provided');
}
const questions = getReturnProcessQuestions(returnProcess);
if (!questions) {
return undefined;
return undefined; // Prozess soll weitergehen, daher kein Error
}
const allQuestionsAnswered = allReturnProcessQuestionsAnswered({
@@ -40,7 +108,7 @@ export class ReturnCanReturnService {
});
if (!allQuestionsAnswered) {
return undefined;
return undefined; // Prozess soll weitergehen, daher kein Error
}
const returnReceiptValues = returnReceiptValuesMapping(
@@ -51,25 +119,29 @@ export class ReturnCanReturnService {
ReturnReceiptValuesSchema.safeParse(returnReceiptValues);
if (!parsedPayload.success) {
this.#logger.error(
'CanReturn Payload validation failed',
parsedPayload.error,
);
return undefined;
throw new Error('CanReturn Payload validation failed');
}
try {
return await firstValueFrom(
this.#receiptService
.ReceiptCanReturn(parsedPayload.data as ReturnReceiptValuesDTO)
.pipe(
debounceTime(50),
map((res) => res as CanReturn),
),
);
} catch (error) {
this.#logger.error('ReceiptCanReturn failed', error);
return undefined;
return parsedPayload.data;
}
/**
* Validates the provided ReturnReceiptValues payload.
*
* @param returnReceiptValues - The values to validate.
* @returns The validated ReturnReceiptValues.
* @throws Error if payload validation fails.
*/
private _canReturnFromReturnReceiptValues(
returnReceiptValues: ReturnReceiptValues,
): ReturnReceiptValues | undefined {
const parsedPayload =
ReturnReceiptValuesSchema.safeParse(returnReceiptValues);
if (!parsedPayload.success) {
throw new Error('CanReturn Payload validation failed');
}
return parsedPayload.data;
}
}

View File

@@ -1,17 +1,40 @@
import { inject, Injectable } from '@angular/core';
import { FetchReturnDetails, FetchReturnDetailsSchema } from './schemas';
import {
FetchReturnDetails,
FetchReturnDetailsSchema,
ReturnReceiptValues,
} from './schemas';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptService } from '@generated/swagger/oms-api';
import { Receipt } from './models';
import { Receipt, ReceiptItem } from './models';
import { CategoryQuestions } from './questions';
import { KeyValue } from '@angular/common';
import { ReturnCanReturnService } from './return-can-return.service';
/**
* Service responsible for fetching return details for a given receipt.
*/
@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.

View File

@@ -85,11 +85,18 @@
[attr.data-which]="i.product.ean"
/>
</ui-checkbox>
} @else if (showDropdownLoading()) {
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="can-return"
></ui-icon-button>
}
</div>
</ui-item-row>
@if (!canReturn()) {
@if (!canReturnReceiptItem()) {
<div
class="text-isa-accent-red isa-text-body-2-bold flex items-center self-end gap-1 pb-6"
>

View File

@@ -10,14 +10,22 @@ import {
Component,
computed,
effect,
inject,
input,
model,
output,
signal,
WritableSignal,
} from '@angular/core';
import { logger, provideLoggerContext } from '@isa/core/logging';
import { isaActionClose, ProductFormatIconGroup } from '@isa/icons';
import { ReceiptItem } from '@isa/oms/data-access';
import {
CanReturn,
ReceiptItem,
ReturnDetailsService,
} from '@isa/oms/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
CheckboxComponent,
DropdownButtonComponent,
@@ -26,27 +34,6 @@ import {
import { ItemRowComponent } from '@isa/ui/item-rows';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
/**
* 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.
*
* This component is responsible for:
* - Displaying product information including image, name, and price
* - Showing return eligibility status of the item
* - Handling selection of returnable items
* - Managing product category assignment for items with unknown categories
* - Providing category change functionality through dropdown options
*
* @example
* ```html
* <oms-feature-return-details-order-group-item
* [item]="receiptItem"
* [availableCategories]="categoryOptions"
* [(selected)]="isItemSelected"
* (changeCategory)="handleCategoryChange($event)">
* </oms-feature-return-details-order-group-item>
* ```
*/
@Component({
selector: 'oms-feature-return-details-order-group-item',
templateUrl: './return-details-order-group-item.component.html',
@@ -58,13 +45,17 @@ import { NgIconComponent, provideIcons } from '@ng-icons/core';
ProductImageDirective,
CheckboxComponent,
NgIconComponent,
IconButtonComponent,
DatePipe,
CurrencyPipe,
LowerCasePipe,
DropdownButtonComponent,
DropdownOptionComponent,
],
providers: [provideIcons({ ...ProductFormatIconGroup, isaActionClose })],
providers: [
provideIcons({ ...ProductFormatIconGroup, isaActionClose }),
provideLoggerContext({ component: 'ReturnDetailsOrderGroupItemComponent' }),
],
})
export class ReturnDetailsOrderGroupItemComponent {
/**
@@ -73,6 +64,17 @@ export class ReturnDetailsOrderGroupItemComponent {
*/
item = input.required<ReceiptItem>();
/**
* Logger instance specific to this component context.
* Used for structured logging of component events and errors.
*/
#logger = logger();
/**
* Service for handling return details logic and API calls.
*/
#returnDetailsService = inject(ReturnDetailsService);
/**
* Available product categories to select from when an item's category is unknown.
* Presented as key-value pairs where the key is the category code and the value is the display name.
@@ -80,86 +82,137 @@ export class ReturnDetailsOrderGroupItemComponent {
availableCategories = input.required<KeyValue<string, string>[]>();
/**
* Event emitted when the product category is changed.
* Emits an object containing the item and the newly selected category.
* Emits when the product category for the item is changed.
* Payload contains the updated item and the selected category.
*/
changeCategory = output<{ item: ReceiptItem; category: string }>();
/**
* Two-way binding for the selection state of the item.
* When true, the item is selected for return.
* Indicates whether the item is currently selected for return.
*/
selected = model(false);
/**
* Controls the visibility of the category dropdown menu.
* Set to true automatically for items with 'unknown' category.
* Controls the visibility of the category dropdown.
* Set to true if the product category is unknown and the item is returnable.
*/
readonly showDropdown = signal(false);
/**
* Determines if the item can be selected for return.
* An item is selectable if it is returnable and has a known product category.
* Controls the visibility of the category dropdown loading spinner.
*/
readonly selectable = computed(() => {
const productCategory = this.getProductCategory();
const canReturn = this.canReturn();
return canReturn && productCategory !== 'unknown';
});
readonly showDropdownLoading = signal(false);
/**
* Computes whether the item can be returned based on its action properties.
* Evaluates the 'canReturn' action property of the item.
* @returns True if the item can be returned, false otherwise.
* Indicates whether the item is selectable for return.
* This signal is set to true if the item is eligible for return and has a known product category.
* It is managed reactively based on the item's return eligibility and category state.
*/
canReturn = computed(() => {
return this.item()?.actions?.some(
selectable = signal(false);
/**
* 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
});
/**
* Retrieves the product category from the item's features.
* Falls back to 'unknown' if no category is defined.
* @returns The product category string.
* Retrieves the product category for the item.
* Returns 'unknown' if the category is not set.
*/
getProductCategory = computed(() => {
return this.item()?.features?.['category'] || 'unknown';
});
/**
* Retrieves the message explaining whether an item can be returned.
* This message is found in the description of the 'canReturn' action.
* @returns The descriptive message about return eligibility.
* 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
});
/**
* Sets up an effect to automatically show the category dropdown
* when the item has an 'unknown' category.
* Holds the latest result of the canReturn API call for this item.
*/
canReturn: WritableSignal<CanReturn | undefined> = signal(undefined);
/**
* Initializes the component's reactive state management.
*
* This constructor sets up an effect that observes changes to the product category
* and the return eligibility of the item. It ensures that:
* - The category dropdown is shown if the product category is 'unknown' and the item is returnable.
* - The item is only selectable if it is returnable and has a known product category.
*
* This logic enforces UI consistency and prevents invalid selection states.
*
* @remarks
* - The effect is automatically disposed with the component's lifecycle.
* - All state changes are performed via Angular signals for optimal reactivity.
*/
constructor() {
effect(() => {
if (this.getProductCategory() === 'unknown' && this.canReturn()) {
const productCategory = this.getProductCategory();
const canReturnReceiptItem = this.canReturnReceiptItem();
if (productCategory === 'unknown' && canReturnReceiptItem) {
this.showDropdown.set(true);
}
const isSelectable =
canReturnReceiptItem && productCategory !== 'unknown';
if (!isSelectable) {
this.selectable.set(false);
} else {
this.selectable.set(true);
}
});
}
/**
* Updates the product category for the current item.
* Emits the changeCategory event with the item and new category.
* If no category is provided, defaults to 'unknown'.
* Sets the product category for the item and checks if it can be returned.
* Emits the changeCategory event and updates the canReturn signal.
* Logs errors with context if the operation fails.
*
* @param category The new category to assign to the product
* @param category - The new category code to assign to the item, or undefined to reset to 'unknown'.
*/
setProductCategory(category: string | undefined) {
this.changeCategory.emit({
async setProductCategory(category: string | undefined) {
const itemToUpdate = {
item: this.item(),
category: category || 'unknown',
});
};
try {
this.showDropdownLoading.set(true);
this.canReturn.set(undefined);
this.selectable.set(false);
const canReturn =
await this.#returnDetailsService.canReturn(itemToUpdate);
this.canReturn.set(canReturn);
this.changeCategory.emit(itemToUpdate);
this.showDropdownLoading.set(false);
} catch (error) {
this.#logger.error('Failed to setProductCategory', error, {
itemId: this.item().id,
category,
});
this.canReturn.set(undefined);
this.showDropdownLoading.set(false);
}
}
}

View File

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

View File

@@ -118,7 +118,6 @@ export class ReturnDetailsComponent {
item: ReceiptItem;
category: string;
}) {
// TODO: Can-Return request
this.#returnDetailsStore.updateProductCategoryForItem({
receiptId: this.itemId(),
itemId: item.id,

View File

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

View File

@@ -21,6 +21,8 @@ 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 { 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.
@@ -43,9 +45,13 @@ import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
NgIconComponent,
ReturnProcessQuestionsComponent,
ProgressBarComponent,
IconButtonComponent,
ReturnProductInfoComponent,
],
providers: [provideIcons({ isaActionCheck, isaActionClose })],
providers: [
provideIcons({ isaActionCheck, isaActionClose }),
provideLoggerContext({ component: 'ReturnProcessItemComponent' }),
],
})
export class ReturnProcessItemComponent {
EligibleForReturnState = EligibleForReturnState;
@@ -53,6 +59,9 @@ export class ReturnProcessItemComponent {
#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>();
@@ -68,6 +77,16 @@ export class ReturnProcessItemComponent {
);
});
/**
* 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);
/**
@@ -114,6 +133,18 @@ 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();
@@ -121,10 +152,18 @@ export class ReturnProcessItemComponent {
untracked(async () => {
if (returnProcess) {
this.canReturn.set(undefined);
const canReturnResponse =
await this.#returnCanReturnService.canReturn(returnProcess);
this.canReturn.set(canReturnResponse);
try {
const canReturnResponse =
await this.#returnCanReturnService.canReturn(returnProcess);
this.canReturn.set(canReturnResponse);
} catch (error) {
this.#logger.error('Failed to validate return process', error, {
returnProcessId: returnProcess.id,
});
this.canReturn.set(undefined);
}
} else {
this.canReturn.set(undefined);
}
});
});

View File

@@ -12,7 +12,6 @@ import { ButtonComponent } from '@isa/ui/buttons';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import {
CanReturn,
EligibleForReturnState,
ReturnCanReturnService,
ReturnProcess,
@@ -120,28 +119,61 @@ 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 (processes && processes.length > 0) {
const canReturnProcesses = [];
for (const returnProcess of processes) {
if (!Array.isArray(processes) || processes.length === 0) {
return;
}
const canReturnResults: boolean[] = [];
for (const returnProcess of processes) {
try {
const canReturnResponse =
await this.#returnCanReturnService.canReturn(returnProcess);
if (canReturnResponse) {
canReturnProcesses.push(canReturnResponse);
}
canReturnResults.push(Boolean(canReturnResponse?.result));
} catch (error) {
this.#logger.error('Failed to check canReturn for process', error, {
processId: returnProcess.processId,
});
canReturnResults.push(false);
}
}
if (canReturnProcesses.every((p) => p.result)) {
this.canReturn.set(true);
}
if (
canReturnResults.length === processes.length &&
canReturnResults.every(Boolean)
) {
this.canReturn.set(true);
} else {
this.canReturn.set(false);
}
});
});