feat(oms-data-acess), feat(return-review): implement return review functionality

Add new ReturnReview library with core components and service structure:
- Create ReturnReviewService and ReturnReviewStore for task management
- Extract PrintReceipts functionality into dedicated service
- Implement review page components with task listing and completion
- Add support for receipt item tasks data model
- Update error handling with consistent error types
- Add comprehensive JSDoc documentation throughout

The implementation provides the foundation for the return review workflow,
including task listing and completion functionality.

Ref: #4942
This commit is contained in:
Nino
2025-04-29 17:49:11 +02:00
parent 4c79f2d127
commit c98cbd73b1
18 changed files with 477 additions and 105 deletions

View File

@@ -4,7 +4,3 @@ import { ProcessService } from './process.service';
export function injectActivatedProcessId() {
return inject(ProcessService).activatedProcessId;
}
export function injectActivatedProcess() {
return inject(ProcessService).activatedProcess;
}

View File

@@ -22,3 +22,6 @@ 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-review.service';
export * from './lib/return-review.store';

View File

@@ -18,7 +18,7 @@ export class ReturnSearchError extends Error {
}
}
export class ReturnSearchParseQueryTokenError extends ReturnSearchError {
export class ReturnParseQueryTokenError extends ReturnSearchError {
constructor(
public queryToken: QueryTokenInput,
messsage: string,

View File

@@ -2,9 +2,11 @@ export * from './errors';
export * from './models';
export * from './return-details.service';
export * from './return-details.store';
export * from './return-process-questions';
export * from './return-process.service';
export * from './return-process.store';
export * from './return-search.service';
export * from './return-search.store';
export * from './return-print-receipts.service';
export * from './return-review.service';
export * from './return-review.store';
export * from './schemas';

View File

@@ -4,6 +4,7 @@ export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
export * from './receipt-type';

View File

@@ -0,0 +1,5 @@
import { ReceiptItemTaskListItemDTO } from '@generated/swagger/oms-api';
export interface ReceiptItemTaskListItem extends ReceiptItemTaskListItemDTO {
id: number;
}

View File

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

View File

@@ -30,10 +30,7 @@ import {
ReturnReceiptValuesDTO,
} from '@generated/swagger/oms-api';
import { firstValueFrom } from 'rxjs';
import { UiModalService } from '@ui/modal';
import { DomainPrinterService, Printer } from '@domain/printer';
import { PrintModalComponent, PrintModalData } from '@modal/printer';
import { EnvironmentService } from '@core/environment';
import { ReturnPrintReceiptsService } from './return-print-receipts.service';
/**
* Service responsible for managing the return process workflow.
@@ -45,11 +42,7 @@ export class ReturnProcessService {
#logger = logger();
#receiptService = inject(ReceiptService);
#printService = inject(DomainPrinterService);
// TODO: Refactor: CDK Dialog verwenden für neuen Printer Dialog
#environmentSerivce = inject(EnvironmentService);
#uiModal = inject(UiModalService);
#printReceiptsService = inject(ReturnPrintReceiptsService);
/**
* Gets all available product categories that have defined question sets.
@@ -222,6 +215,26 @@ export class ReturnProcessService {
});
}
/**
* Retrieves consolidated return information from a return process.
*
* @param process - The return process to extract information from.
* @returns The consolidated return information or undefined if unable to generate.
* @throws {PropertyNullOrUndefinedError} If questions cannot be found.
*/
getReturnInfo(process: ReturnProcess): ReturnInfo | undefined {
const questions = this.returnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
const answers = process.answers || {};
return getReturnInfo({
questions,
answers,
});
}
/**
* Completes the return process for a collection of return items.
* This method validates all return processes are complete and creates return receipts.
@@ -291,83 +304,8 @@ export class ReturnProcessService {
const receipts = response.result as Receipt[];
const receiptIds = receipts.map((receipt) => receipt.id);
await this.printReturns(receiptIds);
await this.#printReceiptsService.printReturns(receiptIds);
return receipts;
}
// 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;
}
/**
* Retrieves consolidated return information from a return process.
*
* @param process - The return process to extract information from.
* @returns The consolidated return information or undefined if unable to generate.
* @throws {PropertyNullOrUndefinedError} If questions cannot be found.
*/
getReturnInfo(process: ReturnProcess): ReturnInfo | undefined {
const questions = this.returnProcessQuestions(process);
if (!questions) {
throw new PropertyNullOrUndefinedError('questions');
}
const answers = process.answers || {};
return getReturnInfo({
questions,
answers,
});
}
}

View File

@@ -0,0 +1,76 @@
import { inject, Injectable } from '@angular/core';
import { map, Observable, throwError } from 'rxjs';
import { ReceiptItemTaskListItem } from './models';
import { QueryTokenInput, QueryTokenSchema } from './schemas';
import { ZodError } from 'zod';
import { ReturnParseQueryTokenError } from './errors';
import { ReceiptService } from '@generated/swagger/oms-api';
/**
* Service responsible for managing return review operations.
*
* Provides functionality to query receipt item tasks and complete tasks
* in the return review process.
*/
@Injectable({ providedIn: 'root' })
export class ReturnReviewService {
#receiptService = inject(ReceiptService);
/**
* Queries receipt item tasks that are not yet completed.
*
* Constructs a query token with a filter for incomplete tasks,
* validates it using Zod schema validation, and fetches the tasks
* from the receipt service.
*
* @returns An Observable of receipt item task list items that match the query
* @throws ReturnParseQueryTokenError when query token parsing fails
*/
queryReceiptItemTasks(): Observable<ReceiptItemTaskListItem[]> {
let queryToken: QueryTokenInput = {
filter: {
completed: false,
},
};
try {
queryToken = QueryTokenSchema.parse(queryToken);
} catch (error) {
if (error instanceof ZodError) {
return throwError(
() => new ReturnParseQueryTokenError(queryToken, error.message),
);
}
if (error instanceof Error) {
return throwError(
() => new ReturnParseQueryTokenError(queryToken, error.message),
);
}
return throwError(
() => new ReturnParseQueryTokenError(queryToken, 'Unknown error'),
);
}
return this.#receiptService.ReceiptQueryReceiptItemTasks(queryToken).pipe(
map((res) => {
// TODO: Response Error Handling
return res.result as ReceiptItemTaskListItem[];
}),
);
}
/**
* Marks a receipt item task as completed.
*
* @param taskId - The unique identifier of the task to complete
* @returns An Observable containing the updated receipt item task after completion
*/
completeTask(taskId: number) {
return this.#receiptService.ReceiptReceiptItemTaskCompleted(taskId).pipe(
map((res) => {
// TODO: Response Error Handling
return res.result as ReceiptItemTaskListItem;
}),
);
}
}

View File

@@ -0,0 +1,130 @@
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 { AsyncResult, AsyncResultStatus } from '@isa/common/data-access';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { ReturnReviewService } from './return-review.service';
import { pipe, switchMap, tap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
/**
* Represents the structure of a return review task list item in the store.
* Extends the AsyncResult type with an ID field to uniquely identify each entity.
*/
export type ReturnReviewTaskListItems = AsyncResult<
ReceiptItemTaskListItem[] | undefined
> & {
/** The unique identifier for the entity, matching the processId */
id: number;
};
/**
* Default initial state for a new ReturnReviewTaskListItems entity.
* Sets up the entity with undefined data and an Idle status.
*/
const initialEntity: Omit<ReturnReviewTaskListItems, 'id'> = {
data: undefined,
status: AsyncResultStatus.Idle,
};
/**
* Signal store for managing return review task list items.
* Uses NgRx Signals with entity support to track the async state
* of receipt item tasks for different processes.
*/
export const ReturnReviewStore = signalStore(
{ providedIn: 'root' },
withEntities<ReturnReviewTaskListItems>(),
withMethods((store) => ({
/**
* Prepares the store for a data fetch operation for a specific process.
* Creates a new entity if one doesn't exist, or updates an existing entity's status to Pending.
*
* @param id - The process ID to prepare for fetching
*/
beforeFetch(id: number) {
let entity: ReturnReviewTaskListItems | undefined =
store.entityMap()?.[id];
if (!entity) {
entity = {
...initialEntity,
id,
status: AsyncResultStatus.Pending,
};
patchState(store, addEntity(entity));
} else {
patchState(
store,
updateEntity({
id,
changes: { status: AsyncResultStatus.Pending },
}),
);
}
},
/**
* Updates the store with successfully fetched task list items.
* Sets the entity status to Success and stores the retrieved data.
*
* @param id - The process ID to update
* @param data - The fetched receipt item task list items
*/
fetchSuccess(id: number, data: ReceiptItemTaskListItem[]) {
patchState(
store,
updateEntity({
id,
changes: { data, status: AsyncResultStatus.Success },
}),
);
},
/**
* Updates the store with error information when fetching fails.
* Sets the entity status to Error and stores the error for reference.
*
* @param id - The process ID that encountered an error
* @param error - The error that occurred during the fetch operation
*/
fetchError(id: number, error: unknown) {
patchState(
store,
updateEntity({
id,
changes: { error, status: AsyncResultStatus.Error },
}),
);
},
})),
withMethods((store, returnReviewService = inject(ReturnReviewService)) => ({
/**
* Fetches task list items for a specific process ID.
* This is an rxMethod that handles the entire fetch lifecycle:
* - Updates store status to Pending before fetching
* - Calls the ReturnReviewService to query receipt item tasks
* - Updates the store with either success or error status based on the result
*
* @param params - Object containing the processId to fetch tasks for
* @returns An Observable that completes when the fetch operation is done
*/
fetchTaskListItems: rxMethod<{ processId: number }>(
pipe(
tap(({ processId }) => store.beforeFetch(processId)),
switchMap(({ processId }) =>
returnReviewService.queryReceiptItemTasks().pipe(
tapResponse({
next(value) {
store.fetchSuccess(processId, value);
},
error(error) {
store.fetchError(processId, error);
},
}),
),
),
),
),
})),
);

View File

@@ -5,7 +5,7 @@ import { QueryTokenInput, QueryTokenSchema } from './schemas';
import { ReceiptListItem } from './models';
import { ListResponseArgs } from '@isa/common/data-access';
import {
ReturnSearchParseQueryTokenError,
ReturnParseQueryTokenError,
ReturnSearchSearchError,
} from './errors/return-search.error';
import { ZodError } from 'zod';
@@ -36,7 +36,7 @@ export class ReturnSearchService {
*
* @param {QueryTokenInput} queryToken - The query token containing search parameters.
* @returns {Observable<ListResponseArgs<ReceiptListItem>>} An observable containing the search results.
* @throws {ReturnSearchParseQueryTokenError} If the query token is invalid.
* @throws {ReturnParseQueryTokenError} If the query token is invalid.
* @throws {ReturnSearchSearchError} If the search fails due to an API error.
*/
search(
@@ -47,16 +47,16 @@ export class ReturnSearchService {
} catch (error) {
if (error instanceof ZodError) {
return throwError(
() => new ReturnSearchParseQueryTokenError(queryToken, error.message),
() => new ReturnParseQueryTokenError(queryToken, error.message),
);
}
if (error instanceof Error) {
return throwError(
() => new ReturnSearchParseQueryTokenError(queryToken, error.message),
() => new ReturnParseQueryTokenError(queryToken, error.message),
);
}
return throwError(
() => new ReturnSearchParseQueryTokenError(queryToken, 'Unknown error'),
() => new ReturnParseQueryTokenError(queryToken, 'Unknown error'),
);
}

View File

@@ -17,10 +17,10 @@ export const OrderBySchema = z.object({
*/
export const QueryTokenSchema = z.object({
filter: z.record(z.any()).default({}), // Filter criteria as key-value pairs
input: z.record(z.any()).default({}), // Input values for the query
orderBy: z.array(OrderBySchema).default([]), // Sorting parameters
skip: z.number().default(0), // Number of items to skip (for pagination)
take: z.number().default(25), // Number of items to take (page size)
input: z.record(z.any()).default({}).optional(), // Input values for the query
orderBy: z.array(OrderBySchema).default([]).optional(), // Sorting parameters
skip: z.number().default(0).optional(), // Number of items to skip (for pagination)
take: z.number().default(25).optional(), // Number of items to take (page size)
});
/**

View File

@@ -0,0 +1,19 @@
<oms-shared-return-product-info
class="self-start"
[product]="product()"
data-what="component"
data-which="return-product-info"
></oms-shared-return-product-info>
<div data-what="review-list" data-which="processing-comment" class="self-start">
{{ processingComment() }}
</div>
<button
type="button"
uiButton
color="secondary"
(click)="markAsDone.emit(this.item().id)"
data-what="button"
data-which="mark-as-done"
>
Als erledigt markieren
</button>

View File

@@ -0,0 +1,3 @@
:host {
@apply w-full grid grid-cols-[1fr,1fr,auto] p-6 text-isa-secondary-900 items-center;
}

View File

@@ -0,0 +1,47 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
computed,
} from '@angular/core';
import { isaActionCheck } from '@isa/icons';
import { provideIcons } from '@ng-icons/core';
import { ReturnProductInfoComponent } from '@isa/oms/shared/product-info';
import { Product, ReceiptItemTaskListItem } from '@isa/oms/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@Component({
selector: 'oms-feature-return-review-item',
templateUrl: './return-review-item.component.html',
styleUrl: './return-review-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReturnProductInfoComponent, ButtonComponent],
providers: [provideIcons({ isaActionCheck })],
})
export class ReturnReviewItemComponent {
item = input.required<ReceiptItemTaskListItem>();
markAsDone = output<number>();
product = computed(() => {
const item = this.item();
const product = item.product;
if (product) {
return product as Product;
}
return undefined;
});
processingComment = computed(() => {
const item = this.item();
const processingComment = item.processingComment;
if (processingComment) {
return processingComment;
}
return undefined;
});
}

View File

@@ -1 +1,22 @@
<p>ReturnReview works!</p>
<h2 class="isa-text-subtitle-1-regular">Die Rückgabe ware erfolgreich!</h2>
<div class="flex flex-col gap-4 w-full items-center justify-center">
@for (item of taskListItems(); track item.id) {
@defer (on viewport) {
<oms-feature-return-review-item
[item]="item"
(markAsDone)="completeTask($event)"
></oms-feature-return-review-item>
} @placeholder {
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
data-what="load-spinner"
data-which="item-placeholder"
></ui-icon-button>
</div>
}
}
</div>

View File

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

View File

@@ -1,10 +1,71 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
untracked,
} from '@angular/core';
import { ReturnReviewService, ReturnReviewStore } from '@isa/oms/data-access';
import { logger, provideLoggerContext } from '@isa/core/logging';
import { injectActivatedProcessId } from '@isa/core/process';
import { firstValueFrom } from 'rxjs';
import { IconButtonComponent } from '@isa/ui/buttons';
import { ReturnReviewItemComponent } from './return-review-item/return-review-item.component';
@Component({
selector: 'oms-feature-return-review',
imports: [CommonModule],
templateUrl: './return-review.component.html',
styleUrl: './return-review.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent, ReturnReviewItemComponent],
providers: [provideLoggerContext({ component: 'ReturnReviewComponent' })],
})
export class ReturnReviewComponent {}
export class ReturnReviewComponent {
#returnReviewService = inject(ReturnReviewService);
#returnReviewStore = inject(ReturnReviewStore);
#logger = logger();
processId = injectActivatedProcessId();
taskListItems = computed(() => {
const processId = this.processId();
if (!processId) {
return [];
}
return this.#returnReviewStore.entityMap()[processId].data ?? [];
});
constructor() {
effect(() => {
const processId = this.processId();
if (processId) {
untracked(() =>
this.#returnReviewStore.fetchTaskListItems({ processId }),
);
}
});
effect(() => {
const taskListItems = this.taskListItems();
console.log(taskListItems);
});
}
async completeTask(taskId: number) {
try {
const result = await firstValueFrom(
this.#returnReviewService.completeTask(taskId),
);
if (result) {
// TODO: Update List
// this.#returnReviewStore.updateTaskListItem(result);
}
} catch (error) {
this.#logger.error('Error completing task', error, {
function: 'completeTask',
});
}
}
}