Merged PR 1993: feat(action-handler, printing, schemas)

1 commit: Bestellbestätigung drucken
2. commit: Schemas
3. commit: Action/Command handler

feat(action-handler, printing, schemas): add handle command service for automated action execution

Implement HandleCommandService and facade to execute order actions automatically
after reward collection. Add action handler infrastructure with 23 handlers
(Accepted, Arrived, Assembled, etc.). Integrate automatic receipt fetching for
print commands. Add schema validation for command handling and receipt queries.
Update reward confirmation to trigger actions after successful collection.

- Add HandleCommandService with command orchestration
- Add HandleCommandFacade as public API layer
- Create schemas: HandleCommandSchema, FetchReceiptsByOrderItemSubsetIdsSchema
- Add helpers: getMainActions, buildItemQuantityMap
- Register 23 action handlers in reward confirmation routes
- Support PRINT_SHIPPINGNOTE and PRINT_SMALLAMOUNTINVOICE auto-fetching
- Update CoreCommandModule for forRoot/forChild patterns
- Add comprehensive unit tests for new services and helpers
- Apply prettier formatting to command and printing modules

Ref: #5394
This commit is contained in:
Nino Righi
2025-11-03 20:00:53 +00:00
committed by Lorenz Hilpert
parent 53a062dcde
commit a49ea25fd0
53 changed files with 2453 additions and 447 deletions

View File

@@ -25,3 +25,4 @@ export * from './lib/services';
export * from './lib/operators';
export * from './lib/stores';
export * from './lib/resources';
export * from './lib/handler';

View File

@@ -0,0 +1,210 @@
import { TestBed } from '@angular/core/testing';
import { HandleCommandFacade } from './handle-command.facade';
import { HandleCommandService } from '../services';
import {
HandleCommand,
FetchReceiptsByOrderItemSubsetIdsInput,
} from '../schemas';
import { ReceiptType } from '../models/receipt-type';
// Mock the HandleCommandService module
jest.mock('../services', () => ({
HandleCommandService: jest.fn().mockImplementation(() => ({
handle: jest.fn(),
fetchReceiptsByOrderItemSubsetIds: jest.fn(),
})),
}));
describe('HandleCommandFacade', () => {
let facade: HandleCommandFacade;
let mockHandleCommandService: {
handle: jest.Mock;
fetchReceiptsByOrderItemSubsetIds: jest.Mock;
};
beforeEach(() => {
mockHandleCommandService = {
handle: jest.fn(),
fetchReceiptsByOrderItemSubsetIds: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
HandleCommandFacade,
{
provide: HandleCommandService,
useValue: mockHandleCommandService,
},
],
});
facade = TestBed.inject(HandleCommandFacade);
});
it('should create', () => {
// Assert
expect(facade).toBeTruthy();
});
it('should delegate handle call to service', async () => {
// Arrange
const mockParams: HandleCommand = {
action: { key: 'test-action', value: 'Test Action' },
items: [],
};
const mockResult: HandleCommand = {
action: { key: 'test-action', value: 'Test Action' },
items: [],
};
mockHandleCommandService.handle.mockResolvedValue(mockResult);
// Act
const result = await facade.handle(mockParams);
// Assert
expect(mockHandleCommandService.handle).toHaveBeenCalledWith(mockParams);
expect(mockHandleCommandService.handle).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should pass through all parameters to service', async () => {
// Arrange
const mockParams: HandleCommand = {
action: {
key: 'complex-action',
value: 'Complex Action',
command: 'DO_SOMETHING',
},
items: [
{
orderItemId: 123,
orderItemSubsetId: 456,
},
] as any,
compartmentCode: 'ABC123',
compartmentInfo: 'Extra Info',
itemQuantity: { 456: 2 },
order: {
id: 100,
orderNumber: 'ORD-001',
orderType: 1,
} as any,
};
mockHandleCommandService.handle.mockResolvedValue(mockParams);
// Act
await facade.handle(mockParams);
// Assert
expect(mockHandleCommandService.handle).toHaveBeenCalledWith(mockParams);
});
it('should propagate errors from service', async () => {
// Arrange
const mockParams: HandleCommand = {
action: { key: 'error-action', value: 'Error Action' },
items: [],
};
const mockError = new Error('Service error');
mockHandleCommandService.handle.mockRejectedValue(mockError);
// Act & Assert
await expect(facade.handle(mockParams)).rejects.toThrow('Service error');
});
describe('fetchReceiptsByOrderItemSubsetIds', () => {
it('should delegate call to service', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [123, 456, 789],
};
const mockResult = {
receipts: [{ id: 1 }, { id: 2 }],
};
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds.mockResolvedValue(
mockResult,
);
// Act
const result = await facade.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds,
).toHaveBeenCalledWith(mockParams, undefined);
expect(
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds,
).toHaveBeenCalledTimes(1);
expect(result).toEqual(mockResult);
});
it('should pass all parameters with defaults to service', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
eagerLoading: 1,
receiptType: (1 + 64 + 128) as ReceiptType,
ids: [100, 200],
};
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds.mockResolvedValue(
{},
);
// Act
await facade.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds,
).toHaveBeenCalledWith(mockParams, undefined);
});
it('should propagate errors from service', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [111],
};
const mockError = new Error('Fetch receipts error');
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds.mockRejectedValue(
mockError,
);
// Act & Assert
await expect(
facade.fetchReceiptsByOrderItemSubsetIds(mockParams),
).rejects.toThrow('Fetch receipts error');
});
it('should pass abort signal to service when provided', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [123],
};
const abortController = new AbortController();
const mockResult = {
receipts: [{ id: 1 }],
};
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds.mockResolvedValue(
mockResult,
);
// Act
const result = await facade.fetchReceiptsByOrderItemSubsetIds(
mockParams,
abortController.signal,
);
// Assert
expect(
mockHandleCommandService.fetchReceiptsByOrderItemSubsetIds,
).toHaveBeenCalledWith(mockParams, abortController.signal);
expect(result).toEqual(mockResult);
});
});
});

View File

@@ -0,0 +1,25 @@
import { inject, Injectable } from '@angular/core';
import { HandleCommandService } from '../services';
import {
FetchReceiptsByOrderItemSubsetIdsInput,
HandleCommand,
} from '../schemas';
@Injectable()
export class HandleCommandFacade {
#handleCommandService = inject(HandleCommandService);
handle(params: HandleCommand) {
return this.#handleCommandService.handle(params);
}
fetchReceiptsByOrderItemSubsetIds(
params: FetchReceiptsByOrderItemSubsetIdsInput,
abortSignal?: AbortSignal,
) {
return this.#handleCommandService.fetchReceiptsByOrderItemSubsetIds(
params,
abortSignal,
);
}
}

View File

@@ -1,2 +1,3 @@
export { OrderCreationFacade } from './order-creation.facade';
export { OrderRewardCollectFacade } from './order-reward-collect.facade';
export { HandleCommandFacade } from './handle-command.facade';

View File

@@ -0,0 +1,89 @@
import {
AcceptedActionHandler,
ArrivedActionHandler,
AssembledActionHandler,
AvailableForDownloadActionHandler,
BackToStockActionHandler,
CanceledByBuyerActionHandler,
CanceledByRetailerActionHandler,
CanceledBySupplierActionHandler,
CollectOnDeliveryNoteActionHandler,
CollectWithSmallAmountinvoiceActionHandler,
CreateReturnItemActionHandler,
CreateShippingNoteActionHandler,
DeliveredActionHandler,
DetermineSupplierActionHandler,
DispatchedActionHandler,
DownloadedActionHandler,
FetchedActionHandler,
InProcessActionHandler,
NotAvailableActionHandler,
NotFetchedActionHandler,
OrderAtSupplierActionHandler,
OrderingActionHandler,
OverdueActionHandler,
PackedActionHandler,
ParkedActionHandler,
PlacedActionHandler,
PreparationForShippingActionHandler,
PrintCompartmentLabelActionHandler,
PrintPriceDiffQrCodeLabelActionHandler,
PrintShippingNoteActionHandler,
PrintSmallamountinvoiceActionHandler,
ReOrderActionHandler,
ReOrderedActionHandler,
RedirectedInternaqllyActionHandler,
RequestedActionHandler,
ReserverdActionHandler,
ReturnedByBuyerActionHandler,
ShippingNoteActionHandler,
ShopWithKulturpassActionHandler,
SupplierTemporarilyOutOfStockActionHandler,
} from '@domain/oms';
/**
* Array of all available OMS action handlers.
* Used for configuring the CoreCommandModule with all supported order item actions.
*/
export const OMS_ACTION_HANDLERS = [
AcceptedActionHandler,
ArrivedActionHandler,
AssembledActionHandler,
AvailableForDownloadActionHandler,
BackToStockActionHandler,
CanceledByBuyerActionHandler,
CanceledByRetailerActionHandler,
CanceledBySupplierActionHandler,
CreateShippingNoteActionHandler,
DeliveredActionHandler,
DetermineSupplierActionHandler,
DispatchedActionHandler,
DownloadedActionHandler,
FetchedActionHandler,
InProcessActionHandler,
NotAvailableActionHandler,
NotFetchedActionHandler,
OrderAtSupplierActionHandler,
OrderingActionHandler,
OverdueActionHandler,
PackedActionHandler,
ParkedActionHandler,
PlacedActionHandler,
PreparationForShippingActionHandler,
PrintCompartmentLabelActionHandler,
PrintShippingNoteActionHandler,
ReOrderActionHandler,
ReOrderedActionHandler,
RedirectedInternaqllyActionHandler,
RequestedActionHandler,
ReserverdActionHandler,
ReturnedByBuyerActionHandler,
ShippingNoteActionHandler,
SupplierTemporarilyOutOfStockActionHandler,
CollectOnDeliveryNoteActionHandler,
CreateReturnItemActionHandler,
PrintPriceDiffQrCodeLabelActionHandler,
CollectWithSmallAmountinvoiceActionHandler,
PrintSmallamountinvoiceActionHandler,
ShopWithKulturpassActionHandler,
];

View File

@@ -0,0 +1 @@
export * from './action-handler';

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from '@jest/globals';
import { getMainActions } from './get-main-actions.helper';
import { DBHOrderItemListItem } from '../schemas';
describe('getMainActions', () => {
it('should return actions from first item', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1' },
{ key: 'action2', value: 'Action 2' },
],
} as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1' },
{ key: 'action2', value: 'Action 2' },
]);
});
it('should filter out actions with enabled boolean property', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1' },
{ key: 'action2', value: 'Action 2', enabled: true },
{ key: 'action3', value: 'Action 3', enabled: false },
],
} as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([{ key: 'action1', value: 'Action 1' }]);
});
it('should filter out actions containing FETCHED_PARTIAL when isPartial is true', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
},
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
],
} as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items, true);
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{ key: 'action3', value: 'Action 3', command: 'DO_ANOTHER_THING' },
]);
});
it('should include FETCHED_PARTIAL actions when isPartial is false', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
},
],
} as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items, false);
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL_ACTION',
},
]);
});
it('should return empty array when items is undefined', () => {
// Arrange
const items = undefined as unknown as DBHOrderItemListItem[];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([]);
});
it('should return empty array when items array is empty', () => {
// Arrange
const items: DBHOrderItemListItem[] = [];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([]);
});
it('should return empty array when first item has no actions', () => {
// Arrange
const items: DBHOrderItemListItem[] = [{} as DBHOrderItemListItem];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([]);
});
it('should return empty array when first item actions is undefined', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{ actions: undefined } as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items);
// Assert
expect(result).toEqual([]);
});
it('should combine both filters correctly', () => {
// Arrange
const items: DBHOrderItemListItem[] = [
{
actions: [
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
{
key: 'action2',
value: 'Action 2',
command: 'FETCHED_PARTIAL',
enabled: true,
},
{
key: 'action3',
value: 'Action 3',
command: 'FETCHED_PARTIAL_ACTION',
},
{ key: 'action4', value: 'Action 4', enabled: false },
],
} as DBHOrderItemListItem,
];
// Act
const result = getMainActions(items, true);
// Assert
expect(result).toEqual([
{ key: 'action1', value: 'Action 1', command: 'DO_SOMETHING' },
]);
});
});

View File

@@ -0,0 +1,16 @@
import { KeyValueOfStringAndString } from '@isa/common/data-access';
import { DBHOrderItemListItem } from '../schemas';
export const getMainActions = (
items: DBHOrderItemListItem[],
isPartial?: boolean,
): KeyValueOfStringAndString[] => {
const firstItem = items?.find((_) => true);
return (
firstItem?.actions
?.filter((action) => typeof action?.enabled !== 'boolean')
?.filter((action) =>
isPartial ? !action?.command?.includes('FETCHED_PARTIAL') : true,
) ?? []
);
};

View File

@@ -1,2 +1,3 @@
export * from './return-process';
export * from './reward';
export * from './get-main-actions.helper';

View File

@@ -18,6 +18,35 @@ describe('getProcessingStatusState', () => {
// Assert
expect(result).toBe(ProcessingStatusState.Cancelled);
});
it('should return Cancelled when all items are AnsLagerNichtAbgeholt', () => {
// Arrange
const statuses = [
OrderItemProcessingStatusValue.AnsLagerNichtAbgeholt, // 262144
OrderItemProcessingStatusValue.AnsLagerNichtAbgeholt, // 262144
];
// Act
const result = getProcessingStatusState(statuses);
// Assert
expect(result).toBe(ProcessingStatusState.Cancelled);
});
it('should return Cancelled when items are mix of cancelled statuses', () => {
// Arrange
const statuses = [
OrderItemProcessingStatusValue.StorniertKunde, // 512
OrderItemProcessingStatusValue.AnsLagerNichtAbgeholt, // 262144
OrderItemProcessingStatusValue.Storniert, // 1024
];
// Act
const result = getProcessingStatusState(statuses);
// Assert
expect(result).toBe(ProcessingStatusState.Cancelled);
});
});
describe('NotFound status', () => {

View File

@@ -29,7 +29,8 @@ export const getProcessingStatusState = (
(status) =>
status === OrderItemProcessingStatusValue.StorniertKunde ||
status === OrderItemProcessingStatusValue.Storniert ||
status === OrderItemProcessingStatusValue.StorniertLieferant,
status === OrderItemProcessingStatusValue.StorniertLieferant ||
status === OrderItemProcessingStatusValue.AnsLagerNichtAbgeholt,
);
if (allCancelled) {
return ProcessingStatusState.Cancelled;

View File

@@ -1,6 +0,0 @@
export enum Gender {
NotSet = 0,
Neutrum = 1,
Male = 2,
Female = 4,
}

View File

@@ -2,7 +2,6 @@ export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './eligible-for-return';
export * from './gender';
export * from './logistician';
export * from './order';
export * from './processing-status-state';

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
/**
* CRUDA enum
* Can Create, Read, Update, Delete, Archive
*/
export const CRUDA = {
None: 0,
Create: 1,
Read: 2,
Update: 4,
Delete: 8,
Archive: 16,
} as const;
const ALL_FLAGS = Object.values(CRUDA).reduce<number>((a, b) => a | b, 0);
export const CRUDASchema = z
.number()
.int()
.nonnegative()
.refine((val) => (val & ~ALL_FLAGS) === 0, {
message: 'Invalid CRUDA permission: contains unknown flags',
})
.describe('CRUDA permissions (bitflags)');
export type CRUDA = z.infer<typeof CRUDASchema>;

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
import { OrderItemListItemSchema } from './order-item-list-item.schema';
import { OrderItemTypeSchema } from './order-item-type.schema';
/**
* DBH Order Item List Item DTO schema
* Extends OrderItemListItem with additional DBH-specific fields
*/
export const DBHOrderItemListItemSchema = OrderItemListItemSchema.extend({
billingZipCode: z.string().describe('Rechnungs-PLZ').optional(),
externalRepositories: z.string().describe('Externe Lager').optional(),
fetchOnDeliveryNote: z
.boolean()
.describe('Auf Lieferschein holen')
.optional(),
invoiceId: z.number().describe('Rechnung ID').optional(),
logisticianId: z.number().describe('Logistiker ID').optional(),
logisticianName: z.string().describe('Logistiker Name').optional(),
orderItemType: OrderItemTypeSchema.describe('Bestellposten-Typ').optional(),
orderedAtSupplier: z
.string()
.describe('Beim Lieferanten bestellt am')
.optional(),
payerId: z.number().describe('Zahler ID').optional(),
paymentReferenceNumber: z
.string()
.describe('Zahlungsreferenznummer')
.optional(),
shippingNoteId: z.number().describe('Versandschein ID').optional(),
});
export type DBHOrderItemListItem = z.infer<typeof DBHOrderItemListItemSchema>;

View File

@@ -0,0 +1,18 @@
import z from 'zod';
import { ReceiptType } from '../models/receipt-type';
export const FetchReceiptsByOrderItemSubsetIdsSchema = z.object({
eagerLoading: z.number().optional().default(1).describe('Eager loading flag'),
receiptType: z
.custom<ReceiptType>()
.optional()
.default((1 + 64 + 128) as ReceiptType)
.describe('Receipt type'),
ids: z
.array(z.number().describe('Order Item Subset ID'))
.describe('List of order item subset IDs'),
});
export type FetchReceiptsByOrderItemSubsetIdsInput = z.input<
typeof FetchReceiptsByOrderItemSubsetIdsSchema
>;

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
/**
* Gender/Salutation enum
* Geschlecht/Anrede
*/
export const Gender = {
NotSet: 0,
Male: 1,
Female: 2,
Diverse: 4,
} as const;
const ALL_FLAGS = Object.values(Gender).reduce<number>((a, b) => a | b, 0);
export const GenderSchema = z
.nativeEnum(Gender)
.refine((val) => (val & ALL_FLAGS) === val, {
message: 'Invalid gender value',
})
.describe('Gender/Salutation');
export type Gender = z.infer<typeof GenderSchema>;

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
import { KeyValueOfStringAndStringSchema } from '@isa/common/data-access';
import { DBHOrderItemListItemSchema } from './dbh-order-item-list-item.schema';
import { DisplayOrderSchema } from './display-order.schema';
import type { Receipt } from '../models/receipt';
/**
* Schema for the handle command data structure.
* Based on ActionHandlerService.handle() method parameters.
*/
export const HandleCommandSchema = z.object({
/**
* Action to be executed (from KeyValueDTOOfStringAndString)
*/
action: KeyValueOfStringAndStringSchema.describe('Action to execute'),
/**
* List of order item list items (DBHOrderItemListItemDTO[])
*/
items: z
.array(DBHOrderItemListItemSchema)
.describe('List of DBH order item list items'),
/**
* Optional compartment code
*/
compartmentCode: z.string().describe('Compartment code').optional(),
/**
* Optional compartment info
*/
compartmentInfo: z.string().describe('Compartment information').optional(),
/**
* Optional item quantity map (Map<number, number>)
* Native JavaScript Map with number keys and values
*/
itemQuantity: z
.map(z.number(), z.number())
.describe('Item quantity mapping (orderItemSubsetId -> quantity)')
.optional(),
/**
* Optional receipts (ReceiptDTO[])
* Using the Receipt model type directly
*/
receipts: z
.array(z.custom<Receipt>())
.describe('List of receipts')
.optional(),
/**
* Optional order data (OrderDTO)
*/
order: DisplayOrderSchema.describe('Order information').optional(),
});
export type HandleCommand = z.infer<typeof HandleCommandSchema>;

View File

@@ -1,13 +1,27 @@
export * from './cruda.schema';
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-payment.schema';
export * from './display-order.schema';
export * from './environment-channel.schema';
export * from './fetch-order-item-subset.schema';
export * from './fetch-return-details.schema';
export * from './gender.schema';
export * from './handle-command.schema';
export * from './linked-record.schema';
export * from './loyalty-collect-type.schema';
export * from './loyalty.schema';
export * from './order-item-list-item.schema';
export * from './order-item-processing-status-value.schema';
export * from './order-item-type.schema';
export * from './order-loyalty-collect.schema';
export * from './order-type.schema';
export * from './payment-status.schema';
export * from './payment-type.schema';
export * from './price.schema';
export * from './product.schema';
export * from './promotion.schema';
@@ -17,8 +31,5 @@ export * from './return-receipt-values.schema';
export * from './shipping-type.schema';
export * from './terms-of-delivery.schema';
export * from './type-of-delivery.schema';
export * from './loyalty-collect-type.schema';
export * from './order-loyalty-collect.schema';
export * from './fetch-order-item-subset.schema';
export * from './display-order-item-subset.schema';
export * from './order-item-processing-status-value.schema';
export * from './vat-type.schema';
export * from './fetch-receipts-by-order-item-subset-ids.schema';

View File

@@ -0,0 +1,16 @@
import { TouchBaseSchema } from '@isa/common/data-access';
import { z } from 'zod';
/**
* Loyalty DTO schema
*/
export const LoyaltySchema = z
.object({
code: z.string().describe('Code').optional(),
label: z.string().describe('Bezeichner').optional(),
type: z.string().describe('Art').optional(),
value: z.number().describe('Wert').optional(),
})
.extend(TouchBaseSchema.shape);
export type Loyalty = z.infer<typeof LoyaltySchema>;

View File

@@ -0,0 +1,111 @@
import {
KeyValueOfStringAndStringSchema,
DateRangeSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { CRUDASchema } from './cruda.schema';
import { EnvironmentChannelSchema } from './environment-channel.schema';
import { GenderSchema } from './gender.schema';
import { LoyaltySchema } from './loyalty.schema';
import { OrderItemProcessingStatusValueSchema } from './order-item-processing-status-value.schema';
import { OrderTypeSchema } from './order-type.schema';
import { PaymentStatusSchema } from './payment-status.schema';
import { PaymentTypeSchema } from './payment-type.schema';
import { PriceSchema } from './price.schema';
import { ProductSchema } from './product.schema';
import { VATTypeSchema } from './vat-type.schema';
/**
* Order Item List Item DTO schema
* Bestellposten
*/
export const OrderItemListItemSchema = z.object({
actions: z
.array(KeyValueOfStringAndStringSchema)
.describe('Mögliche Aktionen')
.optional(),
buyerNumber: z.string().describe('Auftraggeber-Nr').optional(),
clientChannel: EnvironmentChannelSchema.describe('Bestellkanal').optional(),
compartmentCode: z.string().describe('Abholfachnummer').optional(),
compartmentInfo: z.string().describe('Abholfach-Zusatz').optional(),
cruda: CRUDASchema.describe(
'Can Create, Read, Update, Delete, Archive',
).optional(),
currency: z.string().describe('Währung').optional(),
department: z.string().describe('Abteilung').optional(),
estimatedDelivery: DateRangeSchema.describe(
'Voraussichtlicher Zustellzeitraum',
).optional(),
estimatedShippingDate: z
.string()
.describe('Voraussichtliches Lieferdatum')
.optional(),
features: z
.record(z.string(), z.string())
.describe('Zusätzliche Markierungen')
.optional(),
firstName: z.string().describe('Vorname').optional(),
gender: GenderSchema.describe('Anrede').optional(),
isPrebooked: z
.boolean()
.describe('Bestellunposten wurde vorgemerkt')
.optional(),
label: z.string().describe('Label').optional(),
labelId: z.number().describe('Label PK').optional(),
lastName: z.string().describe('Nachname').optional(),
loyalty: LoyaltySchema.describe('Loyalty').optional(),
orderBranchId: z.number().describe('Bestellfiliale').optional(),
orderDate: z.string().describe('Bestelldatum').optional(),
orderId: z.number().describe('Bestellung ID').optional(),
orderItemId: z.number().describe('Bestellposten ID').optional(),
orderItemNumber: z.string().describe('Bestellpostennummer').optional(),
orderItemPId: z.string().describe('Bestellposten PId').optional(),
orderItemSubsetId: z
.number()
.describe('Bestellposten Teilmenge ID')
.optional(),
orderItemSubsetPId: z
.string()
.describe('Bestellposten Teilmenge PId')
.optional(),
orderItemSubsetUId: z
.string()
.describe('Bestellposten Teilmenge UId')
.optional(),
orderItemUId: z.string().describe('Bestellposten UId').optional(),
orderNumber: z.string().describe('Bestellnummer').optional(),
orderPId: z.string().describe('Bestellung PId').optional(),
orderType: OrderTypeSchema.describe('Art der Bestellung').optional(),
orderUId: z.string().describe('Bestellung UId').optional(),
organisation: z.string().describe('Firma / Organisation').optional(),
overallQuantity: z.number().describe('Menge gesamt').optional(),
paymentProcessing: z.string().describe('Zahungsabwicklung').optional(),
paymentStatus: PaymentStatusSchema.describe('Zahlungsstatus').optional(),
paymentType: PaymentTypeSchema.describe('Zahlungsart').optional(),
pickUpDeadline: z.string().describe('Abholfrist').optional(),
price: z.number().describe('Preis').optional(),
processingStatus:
OrderItemProcessingStatusValueSchema.describe(
'Bearbeitungsstatus',
).optional(),
processingStatusDate: z
.string()
.describe('Bearbeitungsstatus wurde gesetzt am')
.optional(),
product: ProductSchema.describe('Artikel-/Produktdaten').optional(),
quantity: z.number().describe('Menge').optional(),
readyForPickUp: z.string().describe('Im Abholfach seit').optional(),
retailPrice: PriceSchema.describe('VK').optional(),
shopName: z.string().describe('Bestellfiliale').optional(),
specialComment: z.string().describe('Bemerkung zum Bestellposten').optional(),
ssc: z.string().describe('Verfügbarkeitsstatus-Code').optional(),
sscText: z.string().describe('Verfügbarkeitsstatus-Beschreibung').optional(),
supplier: z.string().describe('Lieferant').optional(),
supplierId: z.number().describe('Lieferant PK').optional(),
targetBranch: z.string().describe('Zielfiliale').optional(),
targetBranchId: z.number().describe('Zielfiliale PK').optional(),
title: z.string().describe('Titel').optional(),
vatType: VATTypeSchema.describe('Mehrwertsteuer-Art').optional(),
});
export type OrderItemListItem = z.infer<typeof OrderItemListItemSchema>;

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* Order Item Type enum
* Bestellposten-Typ
*/
export const OrderItemType = {
NotSet: 0,
Single: 1,
Head: 2,
Child: 4,
Service: 8,
Discount: 16,
Shipping: 32,
Gift: 64,
Return: 256,
Cancellation: 512,
Replacement: 1024,
} as const;
const ALL_FLAGS = Object.values(OrderItemType).reduce<number>(
(a, b) => a | b,
0,
);
export const OrderItemTypeSchema = z
.nativeEnum(OrderItemType)
.refine((val) => (val & ALL_FLAGS) === val, {
message: 'Invalid order item type',
})
.describe('Order item type');
export type OrderItemType = z.infer<typeof OrderItemTypeSchema>;

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
/**
* Payment Status enum
* Zahlungsstatus
*/
export const PaymentStatus = {
NotSet: 0,
Open: 1,
InProcess: 2,
Paid: 4,
Refunded: 8,
PartiallyRefunded: 16,
Cancelled: 32,
Failed: 64,
Pending: 128,
Authorized: 256,
Captured: 512,
Voided: 1024,
} as const;
const ALL_FLAGS = Object.values(PaymentStatus).reduce<number>(
(a, b) => a | b,
0,
);
export const PaymentStatusSchema = z
.nativeEnum(PaymentStatus)
.refine((val) => (val & ALL_FLAGS) === val, {
message: 'Invalid payment status',
})
.describe('Payment status');
export type PaymentStatus = z.infer<typeof PaymentStatusSchema>;

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* Payment Type enum
* Zahlungsart
*/
export const PaymentType = {
NotSet: 0,
Cash: 1,
Card: 2,
Invoice: 4,
DirectDebit: 8,
PayPal: 16,
CreditCard: 32,
DebitCard: 64,
BankTransfer: 128,
Prepayment: 256,
OnAccount: 512,
GiftCard: 1024,
Voucher: 2048,
Financing: 4096,
ApplePay: 8192,
GooglePay: 16384,
AmazonPay: 32768,
} as const;
const ALL_FLAGS = Object.values(PaymentType).reduce<number>((a, b) => a | b, 0);
export const PaymentTypeSchema = z
.nativeEnum(PaymentType)
.refine((val) => (val & ALL_FLAGS) === val, {
message: 'Invalid payment type',
})
.describe('Payment type');
export type PaymentType = z.infer<typeof PaymentTypeSchema>;

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
/**
* VAT Type enum
* Art des Mehrwertsteuer-Satzes
*/
export const VATType = {
NotSet: 0,
Standard: 1,
Reduced: 2,
SuperReduced: 4,
Zero: 8,
Exempt: 16,
ReverseCharge: 32,
MarginScheme: 64,
Unknown: 128,
} as const;
const ALL_FLAGS = Object.values(VATType).reduce<number>((a, b) => a | b, 0);
export const VATTypeSchema = z
.nativeEnum(VATType)
.refine((val) => (val & ALL_FLAGS) === val, {
message: 'Invalid VAT type',
})
.describe('VAT Type');
export type VATType = z.infer<typeof VATTypeSchema>;

View File

@@ -0,0 +1,374 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { HandleCommandService } from './handle-command.service';
import { ReceiptService } from '@generated/swagger/oms-api';
import { provideLogging, LogLevel } from '@isa/core/logging';
import { FetchReceiptsByOrderItemSubsetIdsInput } from '../schemas';
import { ReceiptType } from '../models';
// Create simple mock object
const mockCommandService = {
handleCommand: jest.fn(),
};
// Mock the module
jest.mock('@core/command', () => ({
CommandService: jest.fn().mockImplementation(() => mockCommandService),
}));
// Import after mock
import { CommandService } from '@core/command';
describe('HandleCommandService', () => {
let service: HandleCommandService;
let mockReceiptService: any;
beforeEach(() => {
mockReceiptService = {
ReceiptGetReceiptsByOrderItemSubset: jest.fn(),
};
// Reset mock before each test
mockCommandService.handleCommand.mockReset();
TestBed.configureTestingModule({
providers: [
HandleCommandService,
{
provide: CommandService,
useValue: mockCommandService,
},
{
provide: ReceiptService,
useValue: mockReceiptService,
},
provideLogging({ level: LogLevel.Off }),
],
});
service = TestBed.inject(HandleCommandService);
});
describe('handle', () => {
it('should handle PRINT_SHIPPINGNOTE command and fetch receipts automatically', async () => {
// Arrange
const mockParams = {
action: { key: 'test', command: 'PRINT_SHIPPINGNOTE' },
items: [{ orderItemSubsetId: 123 }, { orderItemSubsetId: 456 }],
};
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [
{ item3: { data: { receiptNumber: 'R001' } } },
{ item3: { data: { receiptNumber: 'R002' } } },
],
error: null,
}),
);
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).toHaveBeenCalledWith({
payload: expect.objectContaining({
ids: [123, 456],
}),
});
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'PRINT_SHIPPINGNOTE',
expect.objectContaining({
receipts: expect.arrayContaining([
expect.objectContaining({ receiptNumber: 'R001' }),
expect.objectContaining({ receiptNumber: 'R002' }),
]),
}),
);
});
it('should use provided receipts if already available', async () => {
// Arrange
const providedReceipts = [{ receiptNumber: 'R999' }];
const mockParams = {
action: { key: 'test', command: 'PRINT_SHIPPINGNOTE' },
items: [{ orderItemSubsetId: 123 }],
receipts: providedReceipts,
};
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
// Should NOT call the receipt service
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).not.toHaveBeenCalled();
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'PRINT_SHIPPINGNOTE',
expect.objectContaining({
receipts: providedReceipts,
}),
);
});
it('should handle PRINT_SMALLAMOUNTINVOICE command and fetch receipts', async () => {
// Arrange
const mockParams = {
action: { key: 'test', command: 'PRINT_SMALLAMOUNTINVOICE' },
items: [{ orderItemSubsetId: 789 }],
};
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [{ item3: { data: { receiptNumber: 'R003' } } }],
error: null,
}),
);
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).toHaveBeenCalled();
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'PRINT_SMALLAMOUNTINVOICE',
expect.objectContaining({
receipts: expect.any(Array),
}),
);
});
it('should omit receipts for other commands', async () => {
// Arrange
const mockParams = {
action: { key: 'test', command: 'SOME_OTHER_COMMAND' },
items: [{ orderItemSubsetId: 123 }],
receipts: [{ receiptNumber: 'R001' }],
};
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).not.toHaveBeenCalled();
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'SOME_OTHER_COMMAND',
expect.not.objectContaining({
receipts: expect.anything(),
}),
);
});
it('should remove PRINT_COMPARTMENTLABEL when compartmentCode is present', async () => {
// Arrange
const mockParams = {
action: { key: 'test', command: 'SOME_COMMAND|PRINT_COMPARTMENTLABEL' },
items: [{ orderItemSubsetId: 123 }],
compartmentCode: 'ABC123',
};
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'SOME_COMMAND',
expect.anything(),
);
});
it('should handle empty items array gracefully', async () => {
// Arrange
const mockParams = {
action: { key: 'test', command: 'PRINT_SHIPPINGNOTE' },
items: [],
};
mockCommandService.handleCommand.mockResolvedValue({});
// Act
await service.handle(mockParams as any);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).not.toHaveBeenCalled();
expect(mockCommandService.handleCommand).toHaveBeenCalledWith(
'PRINT_SHIPPINGNOTE',
expect.objectContaining({
receipts: [],
}),
);
});
});
describe('fetchReceiptsByOrderItemSubsetIds', () => {
it('should fetch receipts successfully with minimal params', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [123, 456, 789],
};
const mockReceipts = [
{ id: 1, data: { receiptNumber: 'R001' } },
{ id: 2, data: { receiptNumber: 'R002' } },
];
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [
{ item3: { data: mockReceipts[0].data } },
{ item3: { data: mockReceipts[1].data } },
],
error: null,
}),
);
// Act
const result =
await service.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).toHaveBeenCalledWith({
payload: expect.objectContaining({
ids: [123, 456, 789],
}),
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual(mockReceipts[0].data);
expect(result[1]).toEqual(mockReceipts[1].data);
});
it('should fetch receipts with all params including defaults', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
eagerLoading: 1,
receiptType: (1 + 64 + 128) as ReceiptType,
ids: [100, 200],
};
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [{ item3: { data: { receiptNumber: 'R003' } } }],
error: null,
}),
);
// Act
const result =
await service.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).toHaveBeenCalledWith({
payload: expect.objectContaining({
eagerLoading: 1,
receiptType: 193, // 1 + 64 + 128
ids: [100, 200],
}),
});
expect(result).toHaveLength(1);
});
it('should filter out null/undefined receipts from result', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [111, 222],
};
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [
{ item3: { data: { receiptNumber: 'R004' } } },
{ item3: null },
{ item3: { data: null } },
{ item3: { data: { receiptNumber: 'R005' } } },
],
error: null,
}),
);
// Act
const result =
await service.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(result).toHaveLength(2);
expect(result[0].receiptNumber).toBe('R004');
expect(result[1].receiptNumber).toBe('R005');
});
it('should return empty array when no receipts found', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [999],
};
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [],
error: null,
}),
);
// Act
const result =
await service.fetchReceiptsByOrderItemSubsetIds(mockParams);
// Assert
expect(result).toEqual([]);
});
it('should handle abort signal when provided', async () => {
// Arrange
const mockParams: FetchReceiptsByOrderItemSubsetIdsInput = {
ids: [123],
};
const abortController = new AbortController();
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset.mockReturnValue(
of({
result: [{ item3: { data: { receiptNumber: 'R006' } } }],
error: null,
}),
);
// Act
const result = await service.fetchReceiptsByOrderItemSubsetIds(
mockParams,
abortController.signal,
);
// Assert
expect(result).toHaveLength(1);
expect(
mockReceiptService.ReceiptGetReceiptsByOrderItemSubset,
).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,102 @@
import { inject, Injectable } from '@angular/core';
import { CommandService } from '@core/command';
import {
FetchReceiptsByOrderItemSubsetIdsInput,
FetchReceiptsByOrderItemSubsetIdsSchema,
HandleCommand,
HandleCommandSchema,
} from '../schemas';
import { logger } from '@isa/core/logging';
import { ReceiptService } from '@generated/swagger/oms-api';
import { firstValueFrom } from 'rxjs';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { Receipt } from '../models';
@Injectable()
export class HandleCommandService {
#logger = logger(() => ({ service: 'HandleCommandService' }));
#receiptService = inject(ReceiptService);
// TODO: Befindet sich in der alten ISA (@core/command) - Als Lib auslagern
#commandService = inject(CommandService);
async handle(params: HandleCommand): Promise<HandleCommand> {
const parsed = HandleCommandSchema.parse(params);
this.#logger.debug('Handle command', () => ({ parsed }));
let context: HandleCommand;
// Fetch receipts if needed for shipping note or small amount invoice printing
if (
parsed?.action?.command?.includes('PRINT_SHIPPINGNOTE') ||
parsed?.action?.command === 'PRINT_SMALLAMOUNTINVOICE'
) {
// Fetch receipts if not already provided
let receipts = parsed.receipts ?? [];
if (receipts.length === 0 && parsed.items?.length > 0) {
const orderItemSubsetIds = parsed.items
.map((item) => item.orderItemSubsetId)
.filter((id): id is number => id !== undefined && id !== null);
if (orderItemSubsetIds.length > 0) {
receipts = await this.fetchReceiptsByOrderItemSubsetIds({
ids: orderItemSubsetIds,
});
}
}
// Keep receipts - use params with receipts
context = { ...parsed, receipts };
} else {
// Omit receipts from context
const { receipts, ...restParams } = parsed;
context = restParams;
}
// #2737 Bei Zubuchen kein Abholfachzettel ausdrucken
let command = parsed?.action?.command ?? '';
if (parsed?.compartmentCode && !!parsed?.action?.command) {
command = parsed.action.command?.replace('|PRINT_COMPARTMENTLABEL', '');
}
return this.#commandService.handleCommand(command, context);
}
async fetchReceiptsByOrderItemSubsetIds(
params: FetchReceiptsByOrderItemSubsetIdsInput,
abortSignal?: AbortSignal,
) {
const parsed = FetchReceiptsByOrderItemSubsetIdsSchema.parse(params);
this.#logger.debug('Fetch receipts by order item subset IDs', () => ({
parsed,
}));
let req$ = this.#receiptService.ReceiptGetReceiptsByOrderItemSubset({
payload: parsed, // Payload Default from old Implementation, eagerLoading: 1 and receiptType: (1 + 64 + 128) set as Schema default
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
const err = new ResponseArgsError(res);
this.#logger.error(
'Failed to fetch receipts by order item subset IDs',
err,
);
throw err;
}
// Mapping Logic from old implementation
const mappedReceipts =
res?.result?.map((r) => r.item3?.data).filter((f) => !!f) ?? [];
return mappedReceipts as Receipt[];
}
}

View File

@@ -9,3 +9,4 @@ 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

@@ -4,6 +4,7 @@ import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import {
DBHOrderItemListItem,
DisplayOrderItemSubset,
FetchOrderItemSubsetSchema,
FetchOrderItemSubsetSchemaInput,
@@ -42,7 +43,7 @@ export class OrderRewardCollectService {
throw error;
}
return res.result;
return res.result as DBHOrderItemListItem[];
}
async fetchOrderItemSubset(