refactor(checkout): separate data-access layer boundaries

Extract domain-specific operations from CheckoutService into dedicated services to respect data-access layer boundaries and improve separation of concerns.

## Changes

### New Services Created

**OrderCreationService** (oms-data-access)
- createOrdersFromCheckout(): Creates orders from completed checkout
- getLogistician(): Retrieves logistician (default '2470')
- 8 unit tests with Jest/Spectator

**AvailabilityService** (catalogue-data-access)
- validateDownloadAvailabilities(): Validates download items availability
- getDigDeliveryAvailability(): Gets DIG-Versand availability
- getB2bDeliveryAvailability(): Gets B2B-Versand availability
- 15 unit tests with Jest/Spectator

**BranchService** (remission-data-access)
- getDefaultBranch(): Gets default/current branch for user
- 5 unit tests with Angular Testing Utilities
- Note: Temporary location, will move to inventory-data-access

### CheckoutService Refactoring

**Removed cross-domain imports:**
- @generated/swagger/oms-api
- @generated/swagger/availability-api
- @generated/swagger/inventory-api

**Added domain service dependencies:**
- @isa/oms/data-access (OrderCreationService)
- @isa/catalogue/data-access (AvailabilityService)
- @isa/remission/data-access (BranchService)

**Code reduction:**
- Removed 254 lines (25.5% reduction: 996 → 742 lines)
- Deleted 8 private methods moved to domain services

### Test Updates

- Updated checkout.service.spec.ts with new service mocks
- Fixed Zod validation (buyerType/payerType literals)
- All 29 tests passing (8+15+5+11)

### AbortSignal Policy

Removed abortSignal from data-mutating operations:
- OrderCreationService.createOrdersFromCheckout() (POST)
- Kept abortSignal for read-only operations per project convention

## Impact

- Better separation of concerns
- Improved maintainability (smaller, focused services)
- Respects data-access layer boundaries
- No functional changes (100% backward compatible)
- 0 TypeScript compilation errors
This commit is contained in:
Lorenz Hilpert
2025-10-06 17:09:12 +02:00
parent 58815d6fc3
commit 1e9ac30b4d
21 changed files with 2876 additions and 23 deletions

View File

@@ -1,23 +1,24 @@
export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './eligible-for-return';
export * from './gender';
export * from './product';
export * from './quantity';
export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
export * from './receipt-type';
export * from './receipt';
export * from './return-info';
export * from './return-process-answer';
export * from './return-process-question-key';
export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';
export * from './address-type';
export * from './buyer';
export * from './can-return';
export * from './eligible-for-return';
export * from './gender';
export * from './order';
export * from './product';
export * from './quantity';
export * from './receipt-item-list-item';
export * from './receipt-item-task-list-item';
export * from './receipt-item';
export * from './receipt-list-item';
export * from './receipt-type';
export * from './receipt';
export * from './return-info';
export * from './return-process-answer';
export * from './return-process-question-key';
export * from './return-process-question-type';
export * from './return-process-question';
export * from './return-process-status';
export * from './return-process';
export * from './shipping-address-2';
export * from './shipping-type';
export * from './task-action-type';

View File

@@ -0,0 +1,15 @@
import { DisplayOrderDTO } from '@generated/swagger/oms-api';
/**
* Order model representing a completed checkout order.
* This is an alias to the OMS API's DisplayOrderDTO.
*
* @remarks
* DisplayOrderDTO contains:
* - Order metadata (orderNumber, orderDate, orderType, orderValue)
* - Customer information (buyer, payer, shippingAddress)
* - Order items and their details
* - Payment information
* - Branch and delivery information
*/
export type Order = DisplayOrderDTO;

View File

@@ -1,3 +1,4 @@
export * from './order-creation.service';
export * from './return-can-return.service';
export * from './return-details.service';
export * from './print-receipts.service';

View File

@@ -0,0 +1,151 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { OrderCreationService } from './order-creation.service';
import {
OrderCheckoutService,
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { of, NEVER } from 'rxjs';
import { Order } from '../models';
describe('OrderCreationService', () => {
let spectator: SpectatorService<OrderCreationService>;
const createService = createServiceFactory({
service: OrderCreationService,
mocks: [OrderCheckoutService, LogisticianService],
});
beforeEach(() => {
spectator = createService();
});
describe('createOrdersFromCheckout', () => {
it('should create orders successfully', async () => {
// Arrange
const checkoutId = 123;
const mockOrders: Order[] = [
{ id: 1 } as Order,
{ id: 2 } as Order,
];
const mockResponse = { result: mockOrders, error: false };
const orderCheckoutService = spectator.inject(OrderCheckoutService);
orderCheckoutService.OrderCheckoutCreateOrderPOST.mockReturnValue(of(mockResponse));
// Act
const result = await spectator.service.createOrdersFromCheckout(checkoutId);
// Assert
expect(result).toEqual(mockOrders);
expect(orderCheckoutService.OrderCheckoutCreateOrderPOST).toHaveBeenCalledWith({
checkoutId,
});
});
it('should throw error if checkoutId is invalid', async () => {
// Act & Assert
await expect(
spectator.service.createOrdersFromCheckout(0),
).rejects.toThrow('Invalid checkoutId: 0');
});
it('should throw error if API response contains error', async () => {
// Arrange
const checkoutId = 123;
const mockResponse = { error: true, result: undefined };
const orderCheckoutService = spectator.inject(OrderCheckoutService);
orderCheckoutService.OrderCheckoutCreateOrderPOST.mockReturnValue(of(mockResponse));
// Act & Assert
await expect(
spectator.service.createOrdersFromCheckout(checkoutId),
).rejects.toThrow();
});
});
describe('getLogistician', () => {
it('should get default logistician (2470) successfully', async () => {
// Arrange
const mockLogistician: LogisticianDTO = {
logisticianNumber: '2470',
name: 'Default Logistician',
} as LogisticianDTO;
const mockResponse = {
result: [mockLogistician],
error: false,
};
const logisticianService = spectator.inject(LogisticianService);
logisticianService.LogisticianGetLogisticians.mockReturnValue(of(mockResponse));
// Act
const result = await spectator.service.getLogistician();
// Assert
expect(result).toEqual(mockLogistician);
expect(logisticianService.LogisticianGetLogisticians).toHaveBeenCalledWith({});
});
it('should get specific logistician by number', async () => {
// Arrange
const mockLogisticians: LogisticianDTO[] = [
{ logisticianNumber: '1000', name: 'Logistician 1' } as LogisticianDTO,
{ logisticianNumber: '2000', name: 'Logistician 2' } as LogisticianDTO,
];
const mockResponse = {
result: mockLogisticians,
error: false,
};
const logisticianService = spectator.inject(LogisticianService);
logisticianService.LogisticianGetLogisticians.mockReturnValue(of(mockResponse));
// Act
const result = await spectator.service.getLogistician('2000');
// Assert
expect(result).toEqual(mockLogisticians[1]);
});
it('should throw error if logistician not found', async () => {
// Arrange
const mockResponse = {
result: [
{ logisticianNumber: '1000', name: 'Other' } as LogisticianDTO,
],
error: false,
};
const logisticianService = spectator.inject(LogisticianService);
logisticianService.LogisticianGetLogisticians.mockReturnValue(of(mockResponse));
// Act & Assert
await expect(
spectator.service.getLogistician('2470'),
).rejects.toThrow('Logistician 2470 not found');
});
it('should throw error if API response contains error', async () => {
// Arrange
const mockResponse = { error: true, result: undefined };
const logisticianService = spectator.inject(LogisticianService);
logisticianService.LogisticianGetLogisticians.mockReturnValue(of(mockResponse));
// Act & Assert
await expect(
spectator.service.getLogistician(),
).rejects.toThrow();
});
it('should handle observable that never emits', async () => {
// Arrange
const logisticianService = spectator.inject(LogisticianService);
logisticianService.LogisticianGetLogisticians.mockReturnValue(NEVER);
// Act & Assert
const fetchPromise = spectator.service.getLogistician();
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100),
);
await expect(Promise.race([fetchPromise, timeout])).rejects.toThrow(
'Timeout',
);
});
});
});

View File

@@ -0,0 +1,87 @@
import { inject, Injectable } from '@angular/core';
import {
OrderCheckoutService,
LogisticianService,
LogisticianDTO,
} from '@generated/swagger/oms-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
import { Order } from '../models';
/**
* Service for creating orders from checkout.
*
* @remarks
* This service handles order creation operations that are part of the OMS domain.
* It provides methods to:
* - Create orders from a completed checkout
* - Retrieve logistician information
*/
@Injectable({ providedIn: 'root' })
export class OrderCreationService {
#logger = logger(() => ({ service: 'OrderCreationService' }));
readonly #orderCheckoutService = inject(OrderCheckoutService);
readonly #logisticianService = inject(LogisticianService);
/**
* Creates orders from a checkout.
*
* @param checkoutId - The ID of the checkout to create orders from
* @returns Promise resolving to array of created orders
* @throws {Error} If checkoutId is invalid or order creation fails
*/
async createOrdersFromCheckout(checkoutId: number): Promise<Order[]> {
if (!checkoutId) {
throw new Error(`Invalid checkoutId: ${checkoutId}`);
}
const req$ = this.#orderCheckoutService.OrderCheckoutCreateOrderPOST({
checkoutId,
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to create orders', error);
throw error;
}
return res.result as Order[];
}
/**
* Retrieves logistician information.
*
* @param logisticianNumber - The logistician number to retrieve (defaults to '2470')
* @param abortSignal - Optional signal to abort the operation
* @returns Promise resolving to logistician data
* @throws {Error} If logistician is not found or request fails
*/
async getLogistician(
logisticianNumber = '2470',
abortSignal?: AbortSignal,
): Promise<LogisticianDTO> {
let req$ = this.#logisticianService.LogisticianGetLogisticians({});
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 get logistician', error);
throw error;
}
const logistician = res.result?.find(
(l) => l.logisticianNumber === logisticianNumber,
);
if (!logistician) {
throw new Error(`Logistician ${logisticianNumber} not found`);
}
return logistician;
}
}