Merged PR 2000: open tasks

Related work items: #5309
This commit is contained in:
Lorenz Hilpert
2025-11-06 10:01:41 +00:00
committed by Nino Righi
parent 1d4c900d3a
commit 89b3d9aa60
136 changed files with 5088 additions and 4798 deletions

View File

@@ -0,0 +1,95 @@
import {
computed,
inject,
Injectable,
resource,
signal,
Signal,
} from '@angular/core';
import { OrdersService } from '../services/orders.service';
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Resource for fetching display orders by their numeric IDs.
*
* Provides reactive access to display order data with loading and error states.
* Supports both single and multiple display order fetching.
*
* @example
* ```typescript
* // Fetch single display order
* readonly displayOrdersResource = inject(DisplayOrdersResource);
* this.displayOrdersResource.loadOrder(123);
*
* // Fetch multiple display orders
* this.displayOrdersResource.loadOrders([123, 456, 789]);
*
* // Access data
* const orders = this.displayOrdersResource.orders();
* const isLoading = this.displayOrdersResource.loading();
* ```
*/
@Injectable()
export class DisplayOrdersResource {
#ordersService = inject(OrdersService);
#orderIds = signal<number[] | undefined>(undefined);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
params: computed(() => ({
orderIds: this.#orderIds(),
})),
loader: async ({ params, abortSignal }): Promise<DisplayOrderDTO[]> => {
if (!params?.orderIds?.length) {
return [];
}
return await this.#ordersService.getDisplayOrders(
params.orderIds,
abortSignal,
);
},
defaultValue: [],
});
/**
* Signal containing the array of fetched display orders.
* Returns empty array when loading or on error.
*/
readonly orders: Signal<readonly DisplayOrderDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(() => this.#resource.error()?.message ?? null);
/**
* Load a single display order by its numeric ID
*/
loadOrder(orderId: number): void {
this.#orderIds.set([orderId]);
}
/**
* Load multiple display orders by their numeric IDs
*/
loadOrders(orderIds: number[] | undefined): void {
this.#orderIds.set(orderIds);
}
/**
* Manually refresh the current display orders data
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -1 +1,4 @@
export * from './display-orders.resource';
export * from './open-reward-tasks.resource';
export * from './order-item-subset.resource';
export * from './orders.resource';

View File

@@ -0,0 +1,73 @@
import { computed, inject, Injectable, resource, Signal } from '@angular/core';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { OpenRewardTasksService } from '../services/open-reward-tasks.service';
/**
* Global resource for managing open reward distribution tasks (Prämienausgabe).
*
* Provides reactive access to unfinished reward orders across the application.
* This resource is provided at root level to ensure a single shared instance
* for both the side menu indicator and the reward catalog carousel.
*
* @example
* ```typescript
* // In component
* readonly openTasksResource = inject(OpenRewardTasksResource);
* readonly hasOpenTasks = computed(() =>
* (this.openTasksResource.tasks()?.length ?? 0) > 0
* );
* ```
*/
@Injectable({ providedIn: 'root' })
export class OpenRewardTasksResource {
#openRewardTasksService = inject(OpenRewardTasksService);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
loader: async ({ abortSignal }): Promise<DBHOrderItemListItemDTO[]> => {
return await this.#openRewardTasksService.getOpenRewardTasks(
abortSignal,
);
},
defaultValue: [],
});
/**
* Signal containing the array of open reward tasks.
* Returns empty array when loading or on error.
*/
readonly tasks: Signal<readonly DBHOrderItemListItemDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(
() => this.#resource.error()?.message ?? null,
);
/**
* Signal indicating whether there are any open tasks
*/
readonly hasOpenTasks = computed(() => (this.tasks()?.length ?? 0) > 0);
/**
* Signal containing the count of open tasks
*/
readonly taskCount = computed(() => this.tasks()?.length ?? 0);
/**
* Manually refresh the open tasks data.
* Useful for updating after a task is completed.
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -0,0 +1,92 @@
import {
computed,
inject,
Injectable,
resource,
signal,
Signal,
} from '@angular/core';
import { OrderDTO } from '@generated/swagger/oms-api';
import { OrdersService } from '../services/orders.service';
/**
* Resource for fetching orders by their numeric IDs.
*
* Provides reactive access to order data with loading and error states.
* Supports both single and multiple order fetching.
*
* @example
* ```typescript
* // Fetch single order
* readonly ordersResource = inject(OrdersResource);
* this.ordersResource.loadOrder(123);
*
* // Fetch multiple orders
* this.ordersResource.loadOrders([123, 456, 789]);
*
* // Access data
* const orders = this.ordersResource.orders();
* const isLoading = this.ordersResource.loading();
* ```
*/
@Injectable()
export class OrdersResource {
#ordersService = inject(OrdersService);
#orderIds = signal<number[] | undefined>(undefined);
/**
* Internal resource that manages data fetching and caching
*/
#resource = resource({
params: computed(() => ({
orderIds: this.#orderIds(),
})),
loader: async ({ params, abortSignal }): Promise<OrderDTO[]> => {
if (!params?.orderIds?.length) {
return [];
}
return await this.#ordersService.getOrders(params.orderIds, abortSignal);
},
defaultValue: [],
});
/**
* Signal containing the array of fetched orders.
* Returns empty array when loading or on error.
*/
readonly orders: Signal<readonly OrderDTO[]> =
this.#resource.value.asReadonly();
/**
* Signal indicating whether data is currently being fetched
*/
readonly loading: Signal<boolean> = this.#resource.isLoading;
/**
* Signal containing error message if fetch failed, otherwise null
*/
readonly error = computed(() => this.#resource.error()?.message ?? null);
/**
* Load a single order by its numeric ID
*/
loadOrder(orderId: number): void {
this.#orderIds.set([orderId]);
}
/**
* Load multiple orders by their numeric IDs
*/
loadOrders(orderIds: number[] | undefined): void {
this.#orderIds.set(orderIds);
}
/**
* Manually refresh the current orders data
*/
refresh(): void {
this.#resource.reload();
}
}

View File

@@ -1,50 +1,73 @@
import {
BuyerTypeSchema,
EntitySchema,
KeyValueOfStringAndStringSchema,
NotificationChannelSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { DisplayAddresseeSchema } from './display-addressee.schema';
import { DisplayBranchSchema } from './display-branch.schema';
import { DisplayLogisticianSchema } from './display-logistician.schema';
import { DisplayOrderItemSchema } from './display-order-item.schema';
import { DisplayOrderPaymentSchema } from './display-order-payment.schema';
import { EnvironmentChannelSchema } from './environment-channel.schema';
import { LinkedRecordSchema } from './linked-record.schema';
import { OrderTypeSchema } from './order-type.schema';
import { TermsOfDeliverySchema } from './terms-of-delivery.schema';
export const DisplayOrderSchema = z
.object({
actions: z.array(KeyValueOfStringAndStringSchema).describe('Actions').optional(),
buyer: DisplayAddresseeSchema.describe('Buyer information').optional(),
buyerComment: z.string().describe('Buyer comment').optional(),
buyerIsGuestAccount: z.boolean().describe('Buyer is guest account').optional(),
buyerNumber: z.string().describe('Unique buyer identifier number').optional(),
buyerType: BuyerTypeSchema.describe('Buyer type').optional(),
clientChannel: EnvironmentChannelSchema.describe('Client channel').optional(),
completedDate: z.string().describe('Completed date').optional(),
features: z.record(z.string().describe('Features'), z.string()).optional(),
items: z.array(DisplayOrderItemSchema).describe('List of items').optional(),
itemsCount: z.number().describe('Number of itemss').optional(),
linkedRecords: z.array(LinkedRecordSchema).describe('List of linked records').optional(),
logistician: DisplayLogisticianSchema.describe('Logistician information').optional(),
notificationChannels: NotificationChannelSchema.describe('Notification channels').optional(),
orderBranch: DisplayBranchSchema.describe('Order branch').optional(),
orderDate: z.string().describe('Order date').optional(),
orderNumber: z.string().describe('Order number').optional(),
orderType: OrderTypeSchema.describe('Order type'),
orderValue: z.number().describe('Order value').optional(),
orderValueCurrency: z.string().describe('Order value currency').optional(),
payer: DisplayAddresseeSchema.describe('Payer information').optional(),
payerIsGuestAccount: z.boolean().describe('Payer is guest account').optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payment: DisplayOrderPaymentSchema.describe('Payment').optional(),
shippingAddress: DisplayAddresseeSchema.describe('Shipping address information').optional(),
targetBranch: DisplayBranchSchema.describe('Target branch').optional(),
termsOfDelivery: TermsOfDeliverySchema.describe('Terms of delivery').optional(),
})
.extend(EntitySchema.shape);
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;
import {
BuyerTypeSchema,
EntitySchema,
KeyValueOfStringAndStringSchema,
NotificationChannelSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { DisplayAddresseeSchema } from './display-addressee.schema';
import { DisplayBranchSchema } from './display-branch.schema';
import { DisplayLogisticianSchema } from './display-logistician.schema';
import { DisplayOrderItemSchema } from './display-order-item.schema';
import { DisplayOrderPaymentSchema } from './display-order-payment.schema';
import { EnvironmentChannelSchema } from './environment-channel.schema';
import { LinkedRecordSchema } from './linked-record.schema';
import { OrderTypeSchema } from './order-type.schema';
import { TermsOfDeliverySchema } from './terms-of-delivery.schema';
export const DisplayOrderSchema = z
.object({
actions: z
.array(KeyValueOfStringAndStringSchema)
.describe('Actions')
.optional(),
buyer: DisplayAddresseeSchema.describe('Buyer information').optional(),
buyerComment: z.string().describe('Buyer comment').optional(),
buyerIsGuestAccount: z
.boolean()
.describe('Buyer is guest account')
.optional(),
buyerNumber: z
.string()
.describe('Unique buyer identifier number')
.optional(),
buyerType: BuyerTypeSchema.describe('Buyer type').optional(),
clientChannel:
EnvironmentChannelSchema.describe('Client channel').optional(),
completedDate: z.string().describe('Completed date').optional(),
features: z.record(z.string().describe('Features'), z.string()).optional(),
items: z.array(DisplayOrderItemSchema).describe('List of items').optional(),
itemsCount: z.number().describe('Number of itemss').optional(),
linkedRecords: z
.array(LinkedRecordSchema)
.describe('List of linked records')
.optional(),
logistician: DisplayLogisticianSchema.describe(
'Logistician information',
).optional(),
notificationChannels: NotificationChannelSchema.describe(
'Notification channels',
).optional(),
orderBranch: DisplayBranchSchema.describe('Order branch').optional(),
orderDate: z.string().describe('Order date').optional(),
orderNumber: z.string().describe('Order number').optional(),
orderType: OrderTypeSchema.describe('Order type'),
orderValue: z.number().describe('Order value').optional(),
orderValueCurrency: z.string().describe('Order value currency').optional(),
payer: DisplayAddresseeSchema.describe('Payer information').optional(),
payerIsGuestAccount: z
.boolean()
.describe('Payer is guest account')
.optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payment: DisplayOrderPaymentSchema.describe('Payment').optional(),
shippingAddress: DisplayAddresseeSchema.describe(
'Shipping address information',
).optional(),
targetBranch: DisplayBranchSchema.describe('Target branch').optional(),
termsOfDelivery:
TermsOfDeliverySchema.describe('Terms of delivery').optional(),
})
.extend(EntitySchema.shape);
export type DisplayOrder = z.infer<typeof DisplayOrderSchema>;

View File

@@ -3,12 +3,13 @@ export * from './dbh-order-item-list-item.schema';
export * from './display-addressee.schema';
export * from './display-branch.schema';
export * from './display-logistician.schema';
export * from './display-order-item.schema';
export * from './display-order-item-subset.schema';
export * from './display-order-item.schema';
export * from './display-order-payment.schema';
export * from './display-order.schema';
export * from './environment-channel.schema';
export * from './fetch-order-item-subset.schema';
export * from './fetch-receipts-by-order-item-subset-ids.schema';
export * from './fetch-return-details.schema';
export * from './gender.schema';
export * from './handle-command.schema';
@@ -32,4 +33,3 @@ export * from './shipping-type.schema';
export * from './terms-of-delivery.schema';
export * from './type-of-delivery.schema';
export * from './vat-type.schema';
export * from './fetch-receipts-by-order-item-subset-ids.schema';

View File

@@ -1,14 +1 @@
import { z } from 'zod';
export const OrderType = {
NotSet: 0,
Branch: 1,
Mail: 2,
Download: 4,
BranchAndDownload: 5, // Branch | Download
MailAndDownload: 6, // Mail | Download
} as const;
export const OrderTypeSchema = z.nativeEnum(OrderType).describe('Order type');
export type OrderType = z.infer<typeof OrderTypeSchema>;
export { OrderTypeSchema } from '@isa/common/data-access';

View File

@@ -1,6 +1,9 @@
export * from './handle-command.service';
export * from './logistician.service';
export * from './oms-metadata.service';
export * from './open-reward-tasks.service';
export * from './order-creation.service';
export * from './order-reward-collect.service';
export * from './orders.service';
export * from './print-receipts.service';
export * from './print-tolino-return-receipt.service';
export * from './return-can-return.service';
@@ -8,5 +11,3 @@ export * from './return-details.service';
export * from './return-process.service';
export * from './return-search.service';
export * from './return-task-list.service';
export * from './order-reward-collect.service';
export * from './handle-command.service';

View File

@@ -1,41 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { TabService, getMetadataHelper } from '@isa/core/tabs';
import { DisplayOrder, DisplayOrderSchema } from '../schemas';
import { OMS_DISPLAY_ORDERS_KEY } from '../constants';
import z from 'zod';
@Injectable({ providedIn: 'root' })
export class OmsMetadataService {
#tabService = inject(TabService);
getDisplayOrders(tabId: number) {
return getMetadataHelper(
tabId,
OMS_DISPLAY_ORDERS_KEY,
z
.array(DisplayOrderSchema.extend({ shoppingCartId: z.number() }))
.optional(),
this.#tabService.entityMap(),
);
}
addDisplayOrders(
tabId: number,
orders: DisplayOrder[],
shoppingCartId: number,
) {
const existingOrders = this.getDisplayOrders(tabId) || [];
this.#tabService.patchTabMetadata(tabId, {
[OMS_DISPLAY_ORDERS_KEY]: [
...existingOrders,
...orders.map((order) => ({ ...order, shoppingCartId })),
],
});
}
clearDisplayOrders(tabId: number) {
this.#tabService.patchTabMetadata(tabId, {
[OMS_DISPLAY_ORDERS_KEY]: undefined,
});
}
}

View File

@@ -0,0 +1,74 @@
import { inject, Injectable } from '@angular/core';
import {
AbholfachService,
DBHOrderItemListItemDTO,
QueryTokenDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Cache, CacheTimeToLive, InFlight } from '@isa/common/decorators';
/**
* Service for fetching open reward distribution tasks (Prämienausgabe) from the OMS API.
*
* Provides cached access to unfinished reward orders with automatic request deduplication.
* These are reward orders in processing statuses 16 (InPreparation) and 128 (ReadyForPickup)
* that need to be completed.
*/
@Injectable({ providedIn: 'root' })
export class OpenRewardTasksService {
#logger = logger(() => ({ service: 'OpenRewardTasksService' }));
#abholfachService = inject(AbholfachService);
/**
* Fetches open reward distribution tasks (unfinished Prämienausgabe orders).
*
* Returns reward orders that are:
* - In status 16 (InPreparation) or 128 (ReadyForPickup)
* - Flagged as reward items (praemie: "1-")
*
* Results are cached for 1 minute to balance freshness with performance.
* Cache is automatically invalidated on refresh.
*
* @param abortSignal Optional abort signal for request cancellation
* @returns Promise resolving to array of open reward tasks
* @throws ResponseArgsError if the API call fails
*/
async getOpenRewardTasks(
abortSignal?: AbortSignal,
): Promise<DBHOrderItemListItemDTO[]> {
this.#logger.debug('Fetching open reward tasks');
const payload: QueryTokenDTO = {
input: {},
filter: {
orderitemprocessingstatus: '16', // InPreparation(16) and ReadyForPickup(128)
praemie: '1-', // Reward items only
},
orderBy: [],
};
let req$ = this.#abholfachService.AbholfachWarenausgabe(payload);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch open reward tasks', error);
throw error;
}
const tasks = res.result ?? [];
this.#logger.debug('Open reward tasks fetched', () => ({
taskCount: tasks.length,
}));
return tasks;
}
}

View File

@@ -0,0 +1,107 @@
import { inject, Injectable } from '@angular/core';
import {
OrderService,
OrderDTO,
DisplayOrderDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class OrdersService {
#logger = logger(() => ({ service: 'OrdersService' }));
#orderService = inject(OrderService);
/**
* Fetch a single order by its numeric ID
*/
async getOrder(
orderId: number,
abortSignal?: AbortSignal,
): Promise<OrderDTO | null> {
let req$ = this.#orderService.OrderGetOrder(orderId);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch order', { orderId, error });
throw error;
}
return res.result ?? null;
}
/**
* Fetch multiple orders by their numeric IDs
* Uses Promise.all to fetch orders concurrently
*/
async getOrders(
orderIds: number[],
abortSignal?: AbortSignal,
): Promise<OrderDTO[]> {
if (!orderIds.length) {
return [];
}
const promises = orderIds.map((orderId) =>
this.getOrder(orderId, abortSignal),
);
const results = await Promise.all(promises);
// Filter out null results (failed fetches)
return results.filter((order): order is OrderDTO => order !== null);
}
/**
* Fetch a single display order by its numeric ID
*/
async getDisplayOrder(
orderId: number,
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO | null> {
let req$ = this.#orderService.OrderGetDisplayOrder(orderId);
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch display order', { orderId, error });
throw error;
}
return res.result ?? null;
}
/**
* Fetch multiple display orders by their numeric IDs
* Uses Promise.all to fetch orders concurrently
*/
async getDisplayOrders(
orderIds: number[],
abortSignal?: AbortSignal,
): Promise<DisplayOrderDTO[]> {
if (!orderIds.length) {
return [];
}
const promises = orderIds.map((orderId) =>
this.getDisplayOrder(orderId, abortSignal),
);
const results = await Promise.all(promises);
// Filter out null results (failed fetches)
return results.filter((order): order is DisplayOrderDTO => !!order);
}
}