Merged PR 1967: Reward Shopping Cart Implementation

This commit is contained in:
Lorenz Hilpert
2025-10-14 16:02:18 +00:00
committed by Nino Righi
parent d761704dc4
commit f15848d5c0
158 changed files with 46339 additions and 39059 deletions

View File

@@ -0,0 +1,7 @@
# availability-data-access
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test availability-data-access` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'availability',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'availability',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,28 @@
{
"name": "availability-data-access",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/availability/data-access/src",
"prefix": "availability",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/availability/data-access"
},
"configurations": {
"ci": {
"mode": "run",
"coverage": {
"enabled": true
}
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,6 @@
export * from './lib/models';
export * from './lib/schemas';
export * from './lib/facades';
export * from './lib/services';
export * from './lib/adapters';
export * from './lib/helpers';

View File

@@ -0,0 +1,160 @@
import { AvailabilityRequestDTO } from '@generated/swagger/availability-api';
import {
GetAvailabilityParams,
GetB2bDeliveryAvailabilityParams,
GetDeliveryAvailabilityParams,
GetDigDeliveryAvailabilityParams,
GetDownloadAvailabilityParams,
GetInStoreAvailabilityParams,
GetPickupAvailabilityParams,
} from '../schemas';
import { Price } from '@isa/common/data-access';
/**
* Adapter for converting validated availability params to API request format.
*
* Maps domain params to AvailabilityRequestDTO[] format required by the generated API client.
*/
export class AvailabilityRequestAdapter {
/**
* Maps optional price object to API format.
* Extracts value and VAT information from domain price structure.
*/
private static mapPrice(price?: Price): AvailabilityRequestDTO['price'] {
if (!price) return undefined;
return {
value: price.value
? {
value: price.value.value,
currency: price.value.currency,
currencySymbol: price.value.currencySymbol,
}
: undefined,
vat: price.vat
? {
value: price.vat.value,
inPercent: price.vat.inPercent,
label: price.vat.label,
vatType: price.vat.vatType,
}
: undefined,
};
}
/**
* Converts Pickup availability params to API request format.
* Uses store availability endpoint with branch context.
*/
static toPickupRequest(
params: GetPickupAvailabilityParams,
): AvailabilityRequestDTO[] {
return params.items.map((item) => ({
ean: item.ean,
itemId: String(item.itemId),
qty: item.quantity,
shopId: params.branchId,
price: this.mapPrice(item.price),
}));
}
/**
* Converts B2B delivery params to API request format.
* Uses store availability endpoint (like Pickup) with branch context.
* Note: Logistician will be overridden to '2470' by the service layer.
*
* @param params B2B availability params (no shopId - uses default branch)
* @param shopId The default branch ID to use (fetched by service)
*/
static toB2bRequest(
params: GetB2bDeliveryAvailabilityParams,
shopId: number,
): AvailabilityRequestDTO[] {
return params.items.map((item) => ({
ean: item.ean,
itemId: String(item.itemId),
qty: item.quantity,
shopId: shopId,
price: this.mapPrice(item.price),
}));
}
/**
* Converts standard Delivery params to API request format.
* Uses shipping availability endpoint.
*/
static toDeliveryRequest(
params: GetDeliveryAvailabilityParams,
): AvailabilityRequestDTO[] {
return params.items.map((item) => ({
ean: item.ean,
itemId: String(item.itemId),
qty: item.quantity,
price: this.mapPrice(item.price),
}));
}
/**
* Converts DIG delivery params to API request format.
* Uses shipping availability endpoint (same as standard Delivery).
*/
static toDigDeliveryRequest(
params: GetDigDeliveryAvailabilityParams,
): AvailabilityRequestDTO[] {
return params.items.map((item) => ({
ean: item.ean,
itemId: String(item.itemId),
qty: item.quantity,
price: this.mapPrice(item.price),
}));
}
/**
* Converts Download params to API request format.
* Uses shipping availability endpoint with quantity forced to 1.
*/
static toDownloadRequest(
params: GetDownloadAvailabilityParams,
): AvailabilityRequestDTO[] {
return params.items.map((item) => ({
ean: item.ean,
itemId: String(item.itemId),
qty: 1, // Always 1 for downloads
price: this.mapPrice(item.price),
}));
}
/**
* Main routing method - converts availability params to API request format.
* Automatically selects the correct conversion based on orderType.
*
* Notes:
* - B2B-Versand is not supported by this method as it requires a separate
* shopId parameter (default branch ID). Use toB2bRequest() directly instead.
* - Rücklage (InStore) is not supported by this method as it uses the stock
* service directly, not the availability API.
*/
static toApiRequest(
params: Exclude<
GetAvailabilityParams,
GetB2bDeliveryAvailabilityParams | GetInStoreAvailabilityParams
>,
): AvailabilityRequestDTO[] {
switch (params.orderType) {
case 'Abholung':
return this.toPickupRequest(params);
case 'Versand':
return this.toDeliveryRequest(params);
case 'DIG-Versand':
return this.toDigDeliveryRequest(params);
case 'Download':
return this.toDownloadRequest(params);
default: {
const _exhaustive: never = params;
throw new Error(
`Unsupported order type: ${JSON.stringify(_exhaustive)}`,
);
}
}
}
}

View File

@@ -0,0 +1,243 @@
import {
getOrderTypeFeature,
OrderType,
ShoppingCartItem,
} from '@isa/checkout/data-access';
import {
GetAvailabilityInputParams,
GetSingleItemAvailabilityInputParams,
} from '../schemas';
// TODO: [Adapter Refactoring - Medium Priority] Replace switch with builder pattern
// Current: 67-line switch statement, 90% duplication (Complexity: 6/10)
// Target: Fluent builder API with type safety
//
// Proposed approach:
// 1. Create AvailabilityParamsBuilder class:
// - withItem(catalogProductNumber, ean, quantity, price) // Fix: type-safe price (not any)
// - withOrderType(orderType)
// - withShopId(shopId)
// - build(): GetAvailabilityInputParams | undefined
//
// 2. Encapsulate business rules in builder:
// - requiresShopId check (InStore, Pickup)
// - Download special case (no quantity)
// - Validation logic
//
// 3. Simplify adapter to:
// return new AvailabilityParamsBuilder()
// .withItem(catalogProductNumber, ean, quantity, price)
// .withOrderType(orderType)
// .withShopId(targetBranch)
// .build();
//
// Benefits:
// - Eliminates switch statement duplication
// - Fixes 'any' type on line 158 (type-safe price parameter)
// - Fluent API makes intent clear
// - Easy to add new order types
// - Encapsulates validation rules
//
// Effort: ~3 hours | Impact: Medium | Risk: Low
// See: complexity-analysis.md (Code Review Section 3, Option 1)
export class GetAvailabilityParamsAdapter {
static fromShoppingCartItem(
item: ShoppingCartItem,
orderType = getOrderTypeFeature(item.features),
): GetAvailabilityInputParams | undefined {
const itemData = this.extractItemData(item);
if (!itemData) {
return undefined;
}
const { catalogProductNumber, quantity, targetBranch, ean } = itemData;
const price = this.preparePriceData(item);
const baseItems = [
this.createBaseItem(catalogProductNumber, ean, quantity, price),
];
switch (orderType) {
case OrderType.InStore:
if (!targetBranch) {
return undefined;
}
return {
orderType: OrderType.InStore,
branchId: targetBranch,
itemsIds: baseItems.map((item) => item.itemId), // Note: itemsIds is array of numbers
};
case OrderType.Pickup:
if (!targetBranch) {
return undefined;
}
return {
orderType: OrderType.Pickup,
branchId: targetBranch,
items: baseItems,
};
case OrderType.Delivery:
return {
orderType: OrderType.Delivery,
items: baseItems,
};
case OrderType.DigitalShipping:
return {
orderType: OrderType.DigitalShipping,
items: baseItems,
};
case OrderType.B2BShipping:
return {
orderType: OrderType.B2BShipping,
items: baseItems,
};
case OrderType.Download:
return {
orderType: OrderType.Download,
items: baseItems.map((item) => ({
itemId: item.itemId,
ean: item.ean,
price: item.price,
// Download doesn't need quantity
})),
};
default:
return undefined;
}
}
/**
* Converts a ShoppingCartItem to single-item availability parameters.
* Returns params for the convenience method that checks only one item.
*
* @param item Shopping cart item to convert
* @returns Single-item availability params or undefined if data is invalid
*/
static fromShoppingCartItemToSingle(
item: ShoppingCartItem,
orderType = getOrderTypeFeature(item.features),
): GetSingleItemAvailabilityInputParams | undefined {
console.log(
'Transforming ShoppingCartItem to single-item availability params',
orderType,
);
// Extract common data
const itemData = this.extractItemData(item);
if (!itemData) {
return undefined;
}
const { catalogProductNumber, quantity, targetBranch, ean } = itemData;
const price = this.preparePriceData(item);
// Create the item object
const itemObj = this.createBaseItem(
catalogProductNumber,
ean,
quantity,
price,
);
// Build single-item params based on order type
switch (orderType) {
case OrderType.InStore:
if (!targetBranch) {
return undefined;
}
return {
orderType,
branchId: targetBranch,
itemId: itemObj.itemId,
};
case OrderType.Pickup:
if (!targetBranch) {
return undefined;
}
return {
orderType,
branchId: targetBranch,
item: itemObj,
};
case OrderType.Delivery:
case OrderType.DigitalShipping:
case OrderType.B2BShipping:
case OrderType.Download:
return {
orderType,
item: itemObj,
};
default:
return undefined;
}
}
/**
* Extracts and validates required data from a ShoppingCartItem.
* @returns Extracted data or undefined if validation fails
*/
private static extractItemData(item: ShoppingCartItem) {
const catalogProductNumber = item.product.catalogProductNumber;
const quantity = item.quantity;
const targetBranch = item.destination?.data?.targetBranch?.id;
const ean = item.product.ean;
if (!catalogProductNumber || !ean || !quantity) {
return undefined;
}
return { catalogProductNumber, quantity, targetBranch, ean };
}
/**
* Prepares price data from a ShoppingCartItem to match PriceSchema structure.
* @returns Formatted price object or undefined
*/
private static preparePriceData(item: ShoppingCartItem) {
return item.availability?.price
? {
value: item.availability.price.value ?? {
value: undefined,
currency: undefined,
currencySymbol: undefined,
},
vat: item.availability.price.vat ?? {
value: undefined,
label: undefined,
inPercent: undefined,
vatType: undefined,
},
}
: undefined;
}
/**
* Creates a base item object for availability requests.
*
* TODO: [Next Sprint] Replace `any` type with proper typing
* - Change parameter type from `price: any` to `price: Price | undefined`
* - Import: import { Price } from '@isa/common/data-access';
* - Ensures compile-time type safety for price transformations
* - Prevents potential runtime errors from invalid price structures
*/
private static createBaseItem(
catalogProductNumber: string | number,
ean: string,
quantity: number,
price: any, // TODO: Replace with `Price | undefined`
) {
return {
itemId: Number(catalogProductNumber),
ean,
quantity,
price,
};
}
}

View File

@@ -0,0 +1,2 @@
export * from './availability-request.adapter';
export * from './get-availability-params.adapter';

View File

@@ -0,0 +1,58 @@
import { inject, Injectable } from '@angular/core';
import { AvailabilityService } from '../services';
import {
GetAvailabilityInputParams,
GetSingleItemAvailabilityInputParams,
} from '../schemas';
// TODO: [Architecture Simplification - Medium Priority] Evaluate facade necessity
// Current: Pass-through wrapper with no added value (delegates directly to service)
// Recommendation: Consider removal if no orchestration/composition is needed
//
// Analysis:
// - Facade pattern is valuable when:
// ✓ Orchestrating multiple services
// ✓ Adding cross-cutting concerns (caching, analytics)
// ✓ Providing simplified API for complex subsystem
//
// - This facade currently:
// ✗ Just delegates to AvailabilityService
// ✗ No orchestration logic
// ✗ No added value over direct service injection
//
// Proposed action:
// 1. If no future orchestration planned:
// - Remove this facade
// - Update components to inject AvailabilityService directly
// - Remove from index.ts exports
//
// 2. If orchestration is planned:
// - Keep facade but add clear documentation
// - Document future intentions (what will be orchestrated)
//
// Benefits (if removed):
// - One less layer of indirection
// - Clearer code path (component → service)
// - Less maintenance burden
// - Facade pattern only where it adds value
//
// Effort: ~1 hour | Impact: Low | Risk: Very Low
// See: complexity-analysis.md (Architecture Section, Issue 2)
@Injectable({ providedIn: 'root' })
export class AvailabilityFacade {
#availabilityService = inject(AvailabilityService);
getAvailabilities(
params: GetAvailabilityInputParams,
abortSignal?: AbortSignal,
) {
return this.#availabilityService.getAvailabilities(params, abortSignal);
}
getAvailability(
params: GetSingleItemAvailabilityInputParams,
abortSignal?: AbortSignal,
) {
return this.#availabilityService.getAvailability(params, abortSignal);
}
}

View File

@@ -0,0 +1 @@
export * from './availability.facade';

View File

@@ -0,0 +1,140 @@
import { Observable, firstValueFrom } from 'rxjs';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
// Lazy logger initialization to avoid injection context issues at module load time
const getApiLogger = () => logger(() => ({ module: 'AvailabilityApiHelpers' }));
/**
* Context information for error logging during API calls.
*/
export interface AvailabilityApiErrorContext {
/** Order type being fetched (e.g., 'Versand', 'DIG-Versand') */
orderType: string;
/** Item IDs being requested */
itemIds: number[];
/** Additional context (e.g., branchId, shopId) */
additional?: Record<string, unknown>;
}
/**
* Generic API response structure from generated services.
*/
export interface ApiResponse<T> {
result?: T | null;
error?: unknown;
}
/**
* Executes an availability API call with standardized error handling and abort support.
*
* This helper centralizes the common pattern of:
* 1. Adding abort signal support to the request
* 2. Awaiting the Observable response
* 3. Checking for errors and throwing ResponseArgsError
* 4. Logging errors with context
* 5. Returning the result
*
* @param request$ - Observable API request to execute
* @param abortSignal - Optional abort signal for request cancellation
* @param errorContext - Context information for error logging
* @returns The API response result
* @throws ResponseArgsError if the API returns an error
*
* @example
* ```typescript
* const availabilities = await executeAvailabilityApiCall(
* this.#service.AvailabilityShippingAvailability(request),
* abortSignal,
* { orderType: 'Versand', itemIds: [123, 456] }
* );
* ```
*/
export async function executeAvailabilityApiCall<T>(
request$: Observable<ApiResponse<T>>,
abortSignal: AbortSignal | undefined,
errorContext: AvailabilityApiErrorContext,
): Promise<T> {
// Add abort signal support if provided
let req$ = request$;
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
// Execute the request
const res = await firstValueFrom(req$);
// Check for errors
if (res.error) {
const err = new ResponseArgsError({
error: true,
message: typeof res.error === 'string' ? res.error : 'An error occurred',
});
getApiLogger().error(
`Failed to get ${errorContext.orderType} availability`,
err,
() => ({
orderType: errorContext.orderType,
itemIds: errorContext.itemIds,
...errorContext.additional,
}),
);
throw err;
}
// Return result (cast needed because API response might be null)
return res.result as T;
}
/**
* Logs the result of an availability check with standardized format.
*
* This helper centralizes success logging for availability operations,
* showing how many items were requested vs available.
*
* @param orderType - Order type that was checked (e.g., 'Versand', 'DIG-Versand')
* @param requestedItemCount - Number of items that were requested
* @param availableItemCount - Number of items that are available
* @param additionalContext - Optional additional context (e.g., shopId, branchId)
*
* @example
* ```typescript
* logAvailabilityResult('Versand', 5, 4, { shopId: 42 });
* // Logs: "Versand availability fetched: 4/5 items available"
* ```
*/
export function logAvailabilityResult(
orderType: string,
requestedItemCount: number,
availableItemCount: number,
additionalContext?: Record<string, unknown>,
): void {
// Logging disabled in helper functions due to injection context limitations in tests
// TODO: Pass logger instance from service if logging is required
// const unavailableCount = requestedItemCount - availableItemCount;
// getApiLogger().info(`${orderType} availability fetched`, () => ({
// requestedItems: requestedItemCount,
// availableItems: availableItemCount,
// unavailableItems: unavailableCount,
// ...additionalContext,
// }));
}
/**
* Safely converts an itemId to a string for use as dictionary key.
*
* This helper ensures consistent string conversion of itemIds across the service.
* Returns empty string if itemId is undefined (should be filtered out earlier).
*
* @param itemId - Item ID to convert
* @returns String representation of itemId
*
* @example
* ```typescript
* const key = convertItemIdToString(123); // Returns: '123'
* const invalid = convertItemIdToString(undefined); // Returns: ''
* ```
*/
export function convertItemIdToString(itemId: number | undefined): string {
return String(itemId ?? '');
}

View File

@@ -0,0 +1,217 @@
import { Availability, AvailabilityType } from '../models';
import { StockInfo } from '@isa/remission/data-access';
import { Supplier } from '@isa/checkout/data-access';
import { selectPreferredAvailability, isDownloadAvailable } from './availability.helpers';
import { logger } from '@isa/core/logging';
// Lazy logger initialization to avoid injection context issues at module load time
const getTransformerLogger = () => logger(() => ({ module: 'AvailabilityTransformers' }));
/**
* Transforms API response array into dictionary grouped by itemId.
*
* This is the standard transformation used by most order types (Pickup, DIG-Versand, B2B-Versand).
* It filters availabilities by itemId, selects the preferred one, and builds a result dictionary.
*
* @param availabilities - Raw availabilities from API
* @param requestedItems - Items that were requested (must have itemId)
* @returns Dictionary of availabilities by itemId
*
* @example
* ```typescript
* const result = transformAvailabilitiesToDictionary(
* apiResponse,
* [{ itemId: 123 }, { itemId: 456 }]
* );
* // Returns: { '123': Availability, '456': Availability }
* ```
*/
export function transformAvailabilitiesToDictionary(
availabilities: Availability[],
requestedItems: Array<{ itemId?: number }>,
): { [itemId: string]: Availability } {
const result: { [itemId: string]: Availability } = {};
for (const item of requestedItems) {
if (!item.itemId) continue;
const itemAvailabilities = availabilities.filter(
(av) => String(av.itemId) === String(item.itemId),
);
const preferred = selectPreferredAvailability(itemAvailabilities);
if (preferred) {
result[String(item.itemId)] = preferred;
}
}
return result;
}
/**
* Transforms API response for standard delivery (Versand) - excludes supplier/logistician.
*
* This transformation differs from other order types by EXCLUDING supplier and logistician fields
* to match the old service behavior. Including these fields causes the backend to
* automatically change the orderType from "Versand" to "DIG-Versand".
*
* Excluded fields:
* - supplierId, supplier
* - logisticianId, logistician
*
* @param availabilities - Raw availabilities from API
* @param requestedItems - Items that were requested
* @returns Dictionary of availabilities by itemId (without supplier/logistician)
*
* @example
* ```typescript
* const result = transformAvailabilitiesToDictionaryWithFieldFilter(
* apiResponse,
* [{ itemId: 123 }]
* );
* // Returns: { '123': Availability } (without supplierId/logisticianId)
* ```
*/
export function transformAvailabilitiesToDictionaryWithFieldFilter(
availabilities: Availability[],
requestedItems: Array<{ itemId?: number }>,
): { [itemId: string]: Availability } {
const result: { [itemId: string]: Availability } = {};
for (const item of requestedItems) {
if (!item.itemId) continue;
const itemAvailabilities = availabilities.filter(
(av) => String(av.itemId) === String(item.itemId),
);
const preferred = selectPreferredAvailability(itemAvailabilities);
if (preferred) {
// Create a copy without supplier/logistician fields
const {
supplierId,
supplier,
logisticianId,
logistician,
...deliveryAvailability
} = preferred;
result[String(item.itemId)] = deliveryAvailability as Availability;
}
}
return result;
}
/**
* Transforms download availabilities with validation.
*
* Download items require special validation:
* - Supplier ID 16 with 0 stock = unavailable
* - Must have valid availability type code
*
* Items that fail validation are excluded from the result (not marked as unavailable).
*
* @param availabilities - Raw availabilities from API
* @param requestedItems - Items that were requested
* @returns Dictionary of validated availabilities by itemId
*
* @example
* ```typescript
* const result = transformDownloadAvailabilitiesToDictionary(
* apiResponse,
* [{ itemId: 123 }, { itemId: 456 }]
* );
* // Returns: { '123': Availability } (only valid downloads)
* ```
*/
export function transformDownloadAvailabilitiesToDictionary(
availabilities: Availability[],
requestedItems: Array<{ itemId?: number }>,
): { [itemId: string]: Availability } {
const result: { [itemId: string]: Availability } = {};
for (const item of requestedItems) {
if (!item.itemId) continue;
const itemAvailabilities = availabilities.filter(
(av) => String(av.itemId) === String(item.itemId),
);
const preferred = selectPreferredAvailability(itemAvailabilities);
// Validate download availability
if (preferred && isDownloadAvailable(preferred)) {
result[String(item.itemId)] = preferred;
}
// Logging disabled in helper functions due to injection context limitations in tests
// TODO: Pass logger instance from service if logging is required
// else {
// getTransformerLogger().warn('Download unavailable for item', () => ({
// itemId: item.itemId,
// supplierId: preferred?.supplierId,
// status: preferred?.status,
// }));
// }
}
return result;
}
/**
* Transforms stock information to availability format (InStore/Rücklage).
*
* This transformation is specific to in-store (Rücklage) availability:
* - Maps stock quantities to availability status
* - Includes supplier information
* - Uses fixed SSC values for in-store items
*
* @param stocks - Stock information from remission service
* @param requestedItemIds - Item IDs that were requested
* @param supplier - Supplier to include in availability (typically supplier 'F')
* @returns Dictionary of availabilities by itemId
*
* @example
* ```typescript
* const result = transformStockToAvailability(
* stockInfos,
* [123, 456],
* takeAwaySupplier
* );
* // Returns: { '123': Availability, '456': Availability }
* ```
*/
export function transformStockToAvailability(
stocks: StockInfo[],
requestedItemIds: Array<number>,
supplier: Supplier,
): { [itemId: string]: Availability } {
const result: { [itemId: string]: Availability } = {};
for (const itemId of requestedItemIds) {
const stockInfo = stocks.find((s) => s.itemId === itemId);
if (!stockInfo) {
continue;
}
const inStock = stockInfo.inStock ?? 0;
const isAvailable = inStock > 0;
result[String(itemId)] = {
status: isAvailable
? AvailabilityType.Available
: AvailabilityType.NotAvailable,
itemId: stockInfo.itemId,
qty: inStock,
ssc: isAvailable ? '999' : '',
sscText: isAvailable ? 'Filialentnahme' : '',
supplierId: supplier?.id,
price: stockInfo.retailPrice,
} satisfies Availability;
}
return result;
}

View File

@@ -0,0 +1,274 @@
import { describe, it, expect } from 'vitest';
import {
isDownloadAvailable,
selectPreferredAvailability,
calculateEstimatedDate,
hasValidPrice,
isPriceMaintained,
} from './availability.helpers';
import { AvailabilityDTO } from '@generated/swagger/availability-api';
describe('Availability Helpers', () => {
const mockAvailability: AvailabilityDTO = {
itemId: 123,
status: 1024,
preferred: 1,
qty: 5,
supplierId: 1,
at: '2025-10-15',
altAt: '2025-10-20',
requestStatusCode: '0',
price: {
value: { value: 19.99, currency: 'EUR', currencySymbol: '€' },
vat: { value: 3.8, inPercent: 19, label: '19%', vatType: 1 },
},
priceMaintained: true,
};
describe('isDownloadAvailable', () => {
it('should return false for null availability', () => {
expect(isDownloadAvailable(null)).toBe(false);
});
it('should return false for undefined availability', () => {
expect(isDownloadAvailable(undefined)).toBe(false);
});
it('should return false for supplier 16 with 0 stock', () => {
const unavailable: AvailabilityDTO = {
...mockAvailability,
supplierId: 16,
qty: 0,
status: 1024,
};
expect(isDownloadAvailable(unavailable)).toBe(false);
});
it('should return true for supplier 16 with stock > 0', () => {
const available: AvailabilityDTO = {
...mockAvailability,
supplierId: 16,
qty: 5,
status: 1024,
};
expect(isDownloadAvailable(available)).toBe(true);
});
it('should return true for valid availability codes', () => {
const validCodes = [2, 32, 256, 1024, 2048, 4096];
for (const code of validCodes) {
const availability: AvailabilityDTO = {
...mockAvailability,
status: code,
supplierId: 1,
};
expect(isDownloadAvailable(availability)).toBe(true);
}
});
it('should return false for invalid availability codes', () => {
const invalidCodes = [0, 1, 512, 8192, 16384, 999];
for (const code of invalidCodes) {
const availability: AvailabilityDTO = {
...mockAvailability,
status: code,
supplierId: 1,
};
expect(isDownloadAvailable(availability)).toBe(false);
}
});
});
describe('selectPreferredAvailability', () => {
it('should return undefined for empty array', () => {
expect(selectPreferredAvailability([])).toBeUndefined();
});
it('should select availability with preferred === 1', () => {
const availabilities: AvailabilityDTO[] = [
{ ...mockAvailability, preferred: 0 },
{ ...mockAvailability, preferred: 1 },
{ ...mockAvailability, preferred: 0 },
];
const result = selectPreferredAvailability(availabilities);
expect(result?.preferred).toBe(1);
});
it('should return first preferred when multiple have preferred === 1', () => {
const availabilities: AvailabilityDTO[] = [
{ ...mockAvailability, preferred: 0, itemId: 1 },
{ ...mockAvailability, preferred: 1, itemId: 2 },
{ ...mockAvailability, preferred: 1, itemId: 3 },
];
const result = selectPreferredAvailability(availabilities);
expect(result?.itemId).toBe(2);
});
it('should return undefined when no preferred availability', () => {
const availabilities: AvailabilityDTO[] = [
{ ...mockAvailability, preferred: 0 },
{ ...mockAvailability, preferred: 0 },
];
expect(selectPreferredAvailability(availabilities)).toBeUndefined();
});
});
describe('calculateEstimatedDate', () => {
it('should return undefined for null availability', () => {
expect(calculateEstimatedDate(null)).toBeUndefined();
});
it('should return undefined for undefined availability', () => {
expect(calculateEstimatedDate(undefined)).toBeUndefined();
});
it('should return altAt when requestStatusCode is 32', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
requestStatusCode: '32',
at: '2025-10-15',
altAt: '2025-10-20',
};
expect(calculateEstimatedDate(availability)).toBe('2025-10-20');
});
it('should return at when requestStatusCode is not 32', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
requestStatusCode: '0',
at: '2025-10-15',
altAt: '2025-10-20',
};
expect(calculateEstimatedDate(availability)).toBe('2025-10-15');
});
it('should return at when requestStatusCode is undefined', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
at: '2025-10-15',
altAt: '2025-10-20',
};
expect(calculateEstimatedDate(availability)).toBe('2025-10-15');
});
it('should handle missing dates', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
at: undefined,
altAt: undefined,
};
expect(calculateEstimatedDate(availability)).toBeUndefined();
});
});
describe('hasValidPrice', () => {
it('should return false for null availability', () => {
expect(hasValidPrice(null)).toBe(false);
});
it('should return false for undefined availability', () => {
expect(hasValidPrice(undefined)).toBe(false);
});
it('should return true for availability with valid price', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
price: {
value: { value: 19.99, currency: 'EUR', currencySymbol: '€' },
},
};
expect(hasValidPrice(availability)).toBe(true);
});
it('should return false for availability without price', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
price: undefined,
};
expect(hasValidPrice(availability)).toBe(false);
});
it('should return false for availability with price value 0', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
price: {
value: { value: 0, currency: 'EUR' },
},
};
expect(hasValidPrice(availability)).toBe(false);
});
it('should return false for availability with negative price', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
price: {
value: { value: -10, currency: 'EUR' },
},
};
expect(hasValidPrice(availability)).toBe(false);
});
it('should return false for availability with missing value object', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
price: {} as any,
};
expect(hasValidPrice(availability)).toBe(false);
});
});
describe('isPriceMaintained', () => {
it('should return false for null availability', () => {
expect(isPriceMaintained(null)).toBe(false);
});
it('should return false for undefined availability', () => {
expect(isPriceMaintained(undefined)).toBe(false);
});
it('should return true when priceMaintained is true', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
priceMaintained: true,
};
expect(isPriceMaintained(availability)).toBe(true);
});
it('should return false when priceMaintained is false', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
priceMaintained: false,
};
expect(isPriceMaintained(availability)).toBe(false);
});
it('should return false when priceMaintained is undefined', () => {
const availability: AvailabilityDTO = {
...mockAvailability,
priceMaintained: undefined,
};
expect(isPriceMaintained(availability)).toBe(false);
});
});
});

View File

@@ -0,0 +1,109 @@
import { Availability, AvailabilityType } from '../models';
/**
* Valid availability status codes for downloads.
*
* These codes indicate that a digital product can be downloaded:
* - 2: Immediately available for download
* - 32: Available with lead time (minor delay)
* - 256: Pre-order available (future availability)
* - 1024: Backorder available (temporary out of stock)
* - 2048: Special order available (requires special handling)
* - 4096: Digital delivery available (standard digital product)
*
* Note: These codes are defined by the availability API service.
*/
const VALID_DOWNLOAD_STATUS_CODES: AvailabilityType[] = [
AvailabilityType.PrebookAtBuyer,
AvailabilityType.PrebookAtRetailer,
AvailabilityType.PrebookAtSupplier,
AvailabilityType.Available,
AvailabilityType.OnDemand,
AvailabilityType.AtProductionDate,
];
/**
* Validates if a download item is available.
*
* Business rules:
* - Supplier ID 16 with 0 stock = unavailable
* - Must have valid availability type code (see VALID_DOWNLOAD_STATUS_CODES)
*
* @param availability The availability DTO to validate
* @returns true if download is available, false otherwise
*/
export function isDownloadAvailable(
availability: Availability | null | undefined,
): boolean {
if (!availability) return false;
// Supplier 16 with 0 in stock is not available
if (availability.supplierId === 16 && availability.qty === 0) {
return false;
}
// Check if status code is valid for downloads
return VALID_DOWNLOAD_STATUS_CODES.includes(availability.status);
}
/**
* Selects the preferred availability from a list of availabilities.
*
* The preferred availability is marked with `preferred === 1` by the API.
*
* @param availabilities List of availability DTOs
* @returns The preferred availability, or undefined if none found
*/
export function selectPreferredAvailability(
availabilities: Availability[],
): Availability | undefined {
return availabilities.find((av) => av.preferred === 1);
}
/**
* Calculates the estimated shipping/delivery date based on API response.
*
* Business rule:
* - If requestStatusCode === '32', use altAt (alternative date)
* - Otherwise, use at (standard date)
*
* @param availability The availability DTO
* @returns The estimated date string, or undefined
*/
export function calculateEstimatedDate(
availability: Availability | null | undefined,
): string | undefined {
if (!availability) return undefined;
return availability.requestStatusCode === '32'
? availability.altAt
: availability.at;
}
/**
* Type guard to check if an availability has a valid price.
*
* @param availability The availability DTO
* @returns true if availability has a price with a value
*/
export function hasValidPrice(
availability: Availability | null | undefined,
): availability is Availability & {
price: NonNullable<Availability['price']>;
} {
return !!(
availability?.price?.value?.value && availability.price.value.value > 0
);
}
/**
* Checks if an availability is price-maintained.
*
* @param availability The availability DTO
* @returns true if price-maintained flag is set
*/
export function isPriceMaintained(
availability: Availability | null | undefined,
): boolean {
return availability?.priceMaintained === true;
}

View File

@@ -0,0 +1,4 @@
export * from './availability.helpers';
export * from './availability-transformers';
export * from './availability-api-helpers';
export * from './single-to-batch-params';

View File

@@ -0,0 +1,89 @@
import {
GetAvailabilityInputParams,
GetSingleItemAvailabilityInputParams,
} from '../schemas';
/**
* Converts single-item availability parameters to batch format.
*
* The batch availability method expects arrays of items, while the single-item
* method accepts a single item. This converter transforms single → batch format
* while preserving all parameters.
*
* Conversion rules by order type:
* - InStore (Rücklage): item.itemId → itemsIds array
* - Pickup (Abholung): item → items array
* - Delivery/DIG/B2B/Download: item → items array
*
* @param params - Single item availability parameters
* @returns Batch availability parameters compatible with getAvailabilities()
*
* @example
* ```typescript
* // InStore example
* const single = { orderType: 'Rücklage', branchId: 42, itemId: 123 };
* const batch = convertSingleItemToBatchParams(single);
* // Returns: { orderType: 'Rücklage', branchId: 42, itemsIds: [123] }
*
* // Pickup example
* const single = { orderType: 'Abholung', branchId: 42, item: { itemId: 123, ... } };
* const batch = convertSingleItemToBatchParams(single);
* // Returns: { orderType: 'Abholung', branchId: 42, items: [{ itemId: 123, ... }] }
* ```
*/
export function convertSingleItemToBatchParams(
params: GetSingleItemAvailabilityInputParams,
): GetAvailabilityInputParams {
if (params.orderType === 'Rücklage') {
// InStore: itemId → itemsIds array
return {
orderType: params.orderType,
branchId: params.branchId,
itemsIds: [params.itemId],
};
} else if (params.orderType === 'Abholung') {
// Pickup: item → items array
return {
orderType: params.orderType,
branchId: params.branchId,
items: [params.item],
};
} else {
// Delivery/DIG/B2B/Download: item → items array
return {
orderType: params.orderType,
items: [params.item],
} satisfies GetAvailabilityInputParams;
}
}
/**
* Extracts the itemId from single-item availability parameters.
*
* Different order types store the itemId in different places:
* - InStore (Rücklage): directly in params.itemId
* - All others: in params.item.itemId
*
* @param params - Single item availability parameters
* @returns The itemId as a string for dictionary key lookup
*
* @example
* ```typescript
* // InStore
* const itemId = extractItemIdFromSingleParams(
* { orderType: 'Rücklage', itemId: 123, ... }
* ); // Returns: '123'
*
* // Other types
* const itemId = extractItemIdFromSingleParams(
* { orderType: 'Versand', item: { itemId: 456 }, ... }
* ); // Returns: '456'
* ```
*/
export function extractItemIdFromSingleParams(
params: GetSingleItemAvailabilityInputParams,
): string {
const itemId =
params.orderType === 'Rücklage' ? params.itemId : params.item.itemId;
return String(itemId);
}

View File

@@ -0,0 +1,16 @@
export const AvailabilityType = {
NotSet: 0,
NotAvailable: 1,
PrebookAtBuyer: 2,
PrebookAtRetailer: 32,
PrebookAtSupplier: 256,
TemporaryNotAvailable: 512,
Available: 1024,
OnDemand: 2048,
AtProductionDate: 4096,
Discontinued: 8192,
EndOfLife: 16384,
} as const;
export type AvailabilityType =
(typeof AvailabilityType)[keyof typeof AvailabilityType];

View File

@@ -0,0 +1,6 @@
import { AvailabilityDTO } from '@generated/swagger/availability-api';
import { AvailabilityType } from './availability-type';
export interface Availability extends Omit<AvailabilityDTO, 'status'> {
status: AvailabilityType;
}

View File

@@ -0,0 +1,3 @@
export * from './availability-type';
export * from './availability';
export * from './order-type';

View File

@@ -0,0 +1 @@
export { OrderType } from '@isa/checkout/data-access';

View File

@@ -0,0 +1,377 @@
import { describe, it, expect } from 'vitest';
import {
GetAvailabilityParamsSchema,
GetInStoreAvailabilityParamsSchema,
GetPickupAvailabilityParamsSchema,
GetDeliveryAvailabilityParamsSchema,
GetDigDeliveryAvailabilityParamsSchema,
GetB2bDeliveryAvailabilityParamsSchema,
GetDownloadAvailabilityParamsSchema,
} from './get-availability-params.schema';
describe('GetAvailabilityParamsSchema', () => {
describe('GetInStoreAvailabilityParamsSchema', () => {
it('should accept valid in-store params', () => {
const validParams = {
orderType: 'Rücklage' as const,
branchId: 42,
itemsIds: [123, 456],
};
const result = GetInStoreAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should coerce string branchId to number', () => {
const params = {
orderType: 'Rücklage' as const,
branchId: '42' as any,
itemsIds: [123],
};
const result = GetInStoreAvailabilityParamsSchema.parse(params);
expect(result.branchId).toBe(42);
});
it('should require itemsIds for in-store', () => {
const invalidParams = {
orderType: 'Rücklage' as const,
branchId: 42,
};
expect(() => GetInStoreAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
it('should accept multiple itemIds', () => {
const params = {
orderType: 'Rücklage' as const,
branchId: 42,
itemsIds: [123, 456, 789],
};
const result = GetInStoreAvailabilityParamsSchema.parse(params);
expect(result.itemsIds).toHaveLength(3);
});
it('should reject negative branchId', () => {
const invalidParams = {
orderType: 'Rücklage' as const,
branchId: -1,
itemsIds: [123],
};
expect(() => GetInStoreAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
it('should reject empty itemsIds array', () => {
const invalidParams = {
orderType: 'Rücklage' as const,
branchId: 42,
itemsIds: [],
};
expect(() => GetInStoreAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
});
describe('GetPickupAvailabilityParamsSchema', () => {
it('should accept valid pickup params', () => {
const validParams = {
orderType: 'Abholung' as const,
branchId: 42,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 1,
},
],
};
const result = GetPickupAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should require branchId for pickup', () => {
const invalidParams = {
orderType: 'Abholung' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
};
expect(() => GetPickupAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
});
describe('GetDeliveryAvailabilityParamsSchema', () => {
it('should accept valid delivery params', () => {
const validParams = {
orderType: 'Versand' as const,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 3,
},
],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should not require shopId for delivery', () => {
const validParams = {
orderType: 'Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(validParams)).not.toThrow();
});
it('should coerce string itemId to number', () => {
const params = {
orderType: 'Versand' as const,
items: [{ itemId: '123' as any, ean: '1234567890', quantity: 1 }],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(params);
expect(result.items[0].itemId).toBe(123);
});
it('should coerce string quantity to number', () => {
const params = {
orderType: 'Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: '5' as any }],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(params);
expect(result.items[0].quantity).toBe(5);
});
it('should reject negative quantity', () => {
const invalidParams = {
orderType: 'Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: -1 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
});
describe('GetDigDeliveryAvailabilityParamsSchema', () => {
it('should accept valid DIG delivery params', () => {
const validParams = {
orderType: 'DIG-Versand' as const,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 2,
},
],
};
const result = GetDigDeliveryAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should not require shopId for DIG delivery', () => {
const validParams = {
orderType: 'DIG-Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
};
expect(() => GetDigDeliveryAvailabilityParamsSchema.parse(validParams)).not.toThrow();
});
});
describe('GetB2bDeliveryAvailabilityParamsSchema', () => {
it('should accept valid B2B delivery params', () => {
const validParams = {
orderType: 'B2B-Versand' as const,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 1,
},
],
};
const result = GetB2bDeliveryAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should not require shopId for B2B delivery (uses default branch)', () => {
const validParams = {
orderType: 'B2B-Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
};
expect(() => GetB2bDeliveryAvailabilityParamsSchema.parse(validParams)).not.toThrow();
});
});
describe('GetDownloadAvailabilityParamsSchema', () => {
it('should accept valid download params', () => {
const validParams = {
orderType: 'Download' as const,
items: [
{
itemId: 123,
ean: '1234567890',
},
],
};
const result = GetDownloadAvailabilityParamsSchema.parse(validParams);
expect(result).toEqual(validParams);
});
it('should not require quantity for downloads', () => {
const validParams = {
orderType: 'Download' as const,
items: [{ itemId: 123, ean: '1234567890' }],
};
expect(() => GetDownloadAvailabilityParamsSchema.parse(validParams)).not.toThrow();
});
it('should not require shopId for downloads', () => {
const validParams = {
orderType: 'Download' as const,
items: [{ itemId: 123, ean: '1234567890' }],
};
expect(() => GetDownloadAvailabilityParamsSchema.parse(validParams)).not.toThrow();
});
});
describe('GetAvailabilityParamsSchema (Union)', () => {
it('should accept any valid order type', () => {
const testCases = [
{ orderType: 'Rücklage' as const, branchId: 42, itemsIds: [123] },
{ orderType: 'Abholung' as const, branchId: 42, items: [{ itemId: 123, ean: '1234567890', quantity: 1 }] },
{ orderType: 'Versand' as const, items: [{ itemId: 123, ean: '1234567890', quantity: 1 }] },
{ orderType: 'DIG-Versand' as const, items: [{ itemId: 123, ean: '1234567890', quantity: 1 }] },
{ orderType: 'B2B-Versand' as const, items: [{ itemId: 123, ean: '1234567890', quantity: 1 }] },
{ orderType: 'Download' as const, items: [{ itemId: 123, ean: '1234567890' }] },
];
for (const params of testCases) {
expect(() => GetAvailabilityParamsSchema.parse(params)).not.toThrow();
}
});
it('should reject invalid order type', () => {
const invalidParams = {
orderType: 'InvalidType',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
};
expect(() => GetAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
});
describe('Price handling', () => {
it('should accept optional price with value and vat', () => {
const paramsWithPrice = {
orderType: 'Versand' as const,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 1,
price: {
value: {
value: 19.99,
currency: 'EUR',
currencySymbol: '€',
},
vat: {
value: 3.8,
inPercent: 19,
label: '19%',
vatType: 1,
},
},
},
],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(paramsWithPrice);
expect(result.items[0].price).toBeDefined();
expect(result.items[0].price?.value?.value).toBe(19.99);
});
it('should accept params without price', () => {
const paramsWithoutPrice = {
orderType: 'Versand' as const,
items: [
{
itemId: 123,
ean: '1234567890',
quantity: 1,
},
],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(paramsWithoutPrice);
expect(result.items[0].price).toBeUndefined();
});
});
describe('Multiple items', () => {
it('should accept multiple items', () => {
const paramsWithMultipleItems = {
orderType: 'Versand' as const,
items: [
{ itemId: 123, ean: '1234567890', quantity: 1 },
{ itemId: 456, ean: '0987654321', quantity: 2 },
{ itemId: 789, ean: '1111111111', quantity: 3 },
],
};
const result = GetDeliveryAvailabilityParamsSchema.parse(paramsWithMultipleItems);
expect(result.items).toHaveLength(3);
});
});
describe('Edge cases', () => {
it('should reject zero itemId', () => {
const invalidParams = {
orderType: 'Versand' as const,
items: [{ itemId: 0, ean: '1234567890', quantity: 1 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
it('should reject zero quantity', () => {
const invalidParams = {
orderType: 'Versand' as const,
items: [{ itemId: 123, ean: '1234567890', quantity: 0 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
it('should reject missing ean', () => {
const invalidParams = {
orderType: 'Versand' as const,
items: [{ itemId: 123, quantity: 1 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
it('should reject missing itemId', () => {
const invalidParams = {
orderType: 'Versand' as const,
items: [{ ean: '1234567890', quantity: 1 }],
};
expect(() => GetDeliveryAvailabilityParamsSchema.parse(invalidParams)).toThrow();
});
});
});

View File

@@ -0,0 +1,194 @@
import z from 'zod';
import { OrderType } from '../models';
import { PriceSchema } from '@isa/common/data-access';
// TODO: [Schema Refactoring - Critical Priority] Eliminate single-item schema duplication
// Current: 12 schemas (6 batch + 6 single-item), 169 lines (Complexity: 8/10)
// Target: 6 schemas with discriminated union, ~80 lines
//
// Proposed approach:
// 1. Use z.discriminatedUnion('orderType', [...]) pattern
// 2. Remove all GetSingle*AvailabilityParamsSchema exports (lines 100-168)
// 3. Handle single-item via adapter pattern:
// - GetAvailabilityParamsAdapter.fromShoppingCartItemToSingle()
// - Transforms batch params → single-item at adapter layer
// 4. Keep helper type: GetSingleItemAvailabilityParams<T> (derived, not validated)
//
// Benefits:
// - 50% reduction in schema count (12 → 6)
// - Single source of truth for validation
// - Better error messages from discriminated union
// - Eliminates maintenance burden (change once, not twice)
//
// Example:
// export const GetAvailabilityParamsSchema = z.discriminatedUnion('orderType', [
// z.object({ orderType: z.literal(OrderType.InStore), shopId: z.coerce.number(), items: ... }),
// z.object({ orderType: z.literal(OrderType.Pickup), shopId: z.coerce.number(), items: ... }),
// // ... other order types
// ]);
//
// Effort: ~3 hours | Impact: High | Risk: Low
// See: complexity-analysis.md (TypeScript Section, Issue 1)
// Base item schema - used for all availability checks
const ItemSchema = z.object({
itemId: z.coerce.number().int().positive(),
ean: z.string(),
price: PriceSchema.optional(),
quantity: z.coerce.number().int().positive().default(1),
});
// Download items don't require quantity (always 1)
const DownloadItemSchema = z.object({
itemId: z.coerce.number().int().positive(),
ean: z.string(),
price: PriceSchema.optional(),
});
const ItemsSchema = z.array(ItemSchema).min(1);
const DownloadItemsSchema = z.array(DownloadItemSchema).min(1);
// In-Store availability (Rücklage) - requires branch context
export const GetInStoreAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.InStore),
branchId: z.coerce.number().int().positive().optional(),
itemsIds: z.array(z.coerce.number().int().positive()).min(1),
});
// Pickup availability (Abholung) - requires branch context
export const GetPickupAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Pickup),
branchId: z.coerce.number().int().positive(),
items: ItemsSchema,
});
// Standard delivery availability (Versand)
export const GetDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Delivery),
items: ItemsSchema,
});
// DIG delivery availability (DIG-Versand) - for webshop customers
export const GetDigDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.DigitalShipping),
items: ItemsSchema,
});
// B2B delivery availability (B2B-Versand) - uses default branch
export const GetB2bDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.B2BShipping),
items: ItemsSchema,
});
// Download availability - quantity always 1
export const GetDownloadAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Download),
items: DownloadItemsSchema,
});
// Union of all availability param types
export const GetAvailabilityParamsSchema = z.union([
GetInStoreAvailabilityParamsSchema,
GetPickupAvailabilityParamsSchema,
GetDeliveryAvailabilityParamsSchema,
GetDigDeliveryAvailabilityParamsSchema,
GetB2bDeliveryAvailabilityParamsSchema,
GetDownloadAvailabilityParamsSchema,
]);
// Type exports
export type GetAvailabilityParams = z.infer<typeof GetAvailabilityParamsSchema>;
export type GetAvailabilityInputParams = z.input<
typeof GetAvailabilityParamsSchema
>;
export type GetInStoreAvailabilityParams = z.infer<
typeof GetInStoreAvailabilityParamsSchema
>;
export type GetPickupAvailabilityParams = z.infer<
typeof GetPickupAvailabilityParamsSchema
>;
export type GetDeliveryAvailabilityParams = z.infer<
typeof GetDeliveryAvailabilityParamsSchema
>;
export type GetDigDeliveryAvailabilityParams = z.infer<
typeof GetDigDeliveryAvailabilityParamsSchema
>;
export type GetB2bDeliveryAvailabilityParams = z.infer<
typeof GetB2bDeliveryAvailabilityParamsSchema
>;
export type GetDownloadAvailabilityParams = z.infer<
typeof GetDownloadAvailabilityParamsSchema
>;
// ========== SINGLE-ITEM SCHEMAS (for convenience methods) ==========
// Single-item schemas use the same structure but accept a single item instead of an array
const SingleInStoreAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.InStore),
branchId: z.coerce.number().int().positive(),
itemId: z.number().int().positive(),
});
const SinglePickupAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Pickup),
branchId: z.coerce.number().int().positive(),
item: ItemSchema,
});
const SingleDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Delivery),
item: ItemSchema,
});
const SingleDigDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.DigitalShipping),
item: ItemSchema,
});
const SingleB2bDeliveryAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.B2BShipping),
item: ItemSchema,
});
const SingleDownloadAvailabilityParamsSchema = z.object({
orderType: z.literal(OrderType.Download),
item: DownloadItemSchema,
});
// Union of all single-item availability param types
export const GetSingleItemAvailabilityParamsSchema = z.union([
SingleInStoreAvailabilityParamsSchema,
SinglePickupAvailabilityParamsSchema,
SingleDeliveryAvailabilityParamsSchema,
SingleDigDeliveryAvailabilityParamsSchema,
SingleB2bDeliveryAvailabilityParamsSchema,
SingleDownloadAvailabilityParamsSchema,
]);
// Single-item type exports
export type GetSingleItemAvailabilityParams = z.infer<
typeof GetSingleItemAvailabilityParamsSchema
>;
export type GetSingleItemAvailabilityInputParams = z.input<
typeof GetSingleItemAvailabilityParamsSchema
>;
export type SingleInStoreAvailabilityParams = z.infer<
typeof SingleInStoreAvailabilityParamsSchema
>;
export type SinglePickupAvailabilityParams = z.infer<
typeof SinglePickupAvailabilityParamsSchema
>;
export type SingleDeliveryAvailabilityParams = z.infer<
typeof SingleDeliveryAvailabilityParamsSchema
>;
export type SingleDigDeliveryAvailabilityParams = z.infer<
typeof SingleDigDeliveryAvailabilityParamsSchema
>;
export type SingleB2bDeliveryAvailabilityParams = z.infer<
typeof SingleB2bDeliveryAvailabilityParamsSchema
>;
export type SingleDownloadAvailabilityParams = z.infer<
typeof SingleDownloadAvailabilityParamsSchema
>;

View File

@@ -0,0 +1 @@
export * from './get-availability-params.schema';

View File

@@ -0,0 +1,710 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AvailabilityService } from './availability.service';
import {
AvailabilityService as GeneratedAvailabilityService,
AvailabilityDTO,
} from '@generated/swagger/availability-api';
import { LogisticianService as GeneratedLogisticianService } from '@generated/swagger/oms-api';
import { ResponseArgsError } from '@isa/common/data-access';
import { BranchService, RemissionStockService } from '@isa/remission/data-access';
import { SupplierService } from '@isa/checkout/data-access';
import { LoggingService } from '@isa/core/logging';
describe('AvailabilityService', () => {
let service: AvailabilityService;
let mockAvailabilityService: any;
let mockLogisticianService: any;
let mockBranchService: any;
let mockStockService: any;
let mockSupplierService: any;
let mockLoggingService: any;
const mockAvailabilityDTO: AvailabilityDTO = {
itemId: 123,
status: 1024,
preferred: 1,
ssc: '10',
sscText: 'Available',
qty: 5,
price: {
value: { value: 19.99, currency: 'EUR', currencySymbol: '€' },
vat: { value: 3.8, inPercent: 19, label: '19%', vatType: 1 },
},
priceMaintained: true,
at: '2025-10-15',
altAt: '2025-10-20',
requestStatusCode: '0',
};
beforeEach(() => {
mockAvailabilityService = {
AvailabilityStoreAvailability: vi.fn(),
AvailabilityShippingAvailability: vi.fn(),
};
mockLogisticianService = {
LogisticianGetLogisticians: vi.fn(),
};
mockBranchService = {
getDefaultBranch: vi.fn(),
};
mockStockService = {
fetchStock: vi.fn(),
fetchStockInfos: vi.fn(),
};
mockSupplierService = {
getTakeAwaySupplier: vi.fn(),
};
mockLoggingService = {
log: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
AvailabilityService,
{ provide: GeneratedAvailabilityService, useValue: mockAvailabilityService },
{ provide: GeneratedLogisticianService, useValue: mockLogisticianService },
{ provide: BranchService, useValue: mockBranchService },
{ provide: RemissionStockService, useValue: mockStockService },
{ provide: SupplierService, useValue: mockSupplierService },
{ provide: LoggingService, useValue: mockLoggingService },
],
});
service = TestBed.inject(AvailabilityService);
});
describe('getAvailabilities', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should throw error for invalid params', async () => {
await expect(service.getAvailabilities({})).rejects.toThrow();
});
it('should throw error for unsupported order type', async () => {
await expect(
service.getAvailabilities({
orderType: 'InvalidType',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
}),
).rejects.toThrow();
});
});
describe('InStore availability (Rücklage)', () => {
it('should fetch in-store availability with branch context', async () => {
const mockStock = { id: 'stock-123', name: 'Test Stock' };
const mockStockInfo = {
itemId: 123,
inStock: 5,
retailPrice: { value: { value: 19.99 } },
};
const mockSupplier = { id: 1, name: 'Supplier F' };
mockStockService.fetchStock.mockResolvedValue(mockStock);
mockStockService.fetchStockInfos.mockResolvedValue([mockStockInfo]);
mockSupplierService.getTakeAwaySupplier.mockResolvedValue(mockSupplier);
const result = await service.getAvailabilities({
orderType: 'Rücklage',
branchId: 42,
itemsIds: [123],
});
expect(mockStockService.fetchStock).toHaveBeenCalledWith(42, undefined);
expect(mockStockService.fetchStockInfos).toHaveBeenCalledWith(
{ itemIds: [123], stockId: 'stock-123' },
undefined,
);
expect(mockSupplierService.getTakeAwaySupplier).toHaveBeenCalledWith(undefined);
expect(result).toHaveProperty('123');
expect(result['123'].itemId).toBe(123);
expect(result['123'].qty).toBe(5);
});
it('should return empty when branch has no stock ID', async () => {
const mockStock = { name: 'Test Stock' }; // No id property
mockStockService.fetchStock.mockResolvedValue(mockStock);
const result = await service.getAvailabilities({
orderType: 'Rücklage',
branchId: 42,
itemsIds: [123],
});
expect(result).toEqual({});
expect(mockStockService.fetchStockInfos).not.toHaveBeenCalled();
});
});
describe('Pickup availability (Abholung)', () => {
it('should fetch pickup availability with branch context', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Abholung',
branchId: 42,
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
});
expect(mockAvailabilityService.AvailabilityStoreAvailability).toHaveBeenCalled();
expect(result).toEqual({ '123': mockAvailabilityDTO });
});
});
describe('Delivery availability (Versand)', () => {
it('should fetch standard delivery availability', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 3 }],
});
expect(mockAvailabilityService.AvailabilityShippingAvailability).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
ean: '1234567890',
itemId: '123',
qty: 3,
}),
]),
);
expect(result).toEqual({ '123': mockAvailabilityDTO });
});
it('should not include shopId for delivery', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
await service.getAvailabilities({
orderType: 'Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
});
const callArgs = mockAvailabilityService.AvailabilityShippingAvailability.mock.calls[0][0];
expect(callArgs[0]).not.toHaveProperty('shopId');
});
});
describe('DIG delivery availability (DIG-Versand)', () => {
it('should fetch DIG delivery availability', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'DIG-Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 2 }],
});
expect(mockAvailabilityService.AvailabilityShippingAvailability).toHaveBeenCalled();
expect(result).toEqual({ '123': mockAvailabilityDTO });
});
});
describe('B2B delivery availability (B2B-Versand)', () => {
const mockLogistician = {
id: 5,
logisticianNumber: '2470',
name: 'DHL',
};
const mockBranch = {
id: 42,
name: 'Test Branch',
branchNumber: '001',
};
beforeEach(() => {
mockLogisticianService.LogisticianGetLogisticians.mockReturnValue(
of({
error: null,
result: [mockLogistician],
message: null,
invalidProperties: null,
}),
);
mockBranchService.getDefaultBranch.mockResolvedValue(mockBranch);
});
it('should fetch B2B availability with logistician override and default branch', async () => {
const mockResponse = {
error: null,
result: [{ ...mockAvailabilityDTO, logisticianId: 99 }],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'B2B-Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
});
expect(mockBranchService.getDefaultBranch).toHaveBeenCalled();
expect(mockLogisticianService.LogisticianGetLogisticians).toHaveBeenCalled();
expect(mockAvailabilityService.AvailabilityStoreAvailability).toHaveBeenCalled();
// Verify logistician was overridden to 2470's ID
expect(result['123'].logisticianId).toBe(5);
});
it('should use store endpoint for B2B', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
await service.getAvailabilities({
orderType: 'B2B-Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
});
expect(mockAvailabilityService.AvailabilityStoreAvailability).toHaveBeenCalled();
expect(mockAvailabilityService.AvailabilityShippingAvailability).not.toHaveBeenCalled();
});
it('should throw error if logistician 2470 not found', async () => {
mockLogisticianService.LogisticianGetLogisticians.mockReturnValue(
of({
error: null,
result: [{ id: 1, logisticianNumber: '1234', name: 'Other' }],
message: null,
invalidProperties: null,
}),
);
await expect(
service.getAvailabilities({
orderType: 'B2B-Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
}),
).rejects.toThrow('Logistician 2470 not found');
});
it('should throw error if default branch has no ID', async () => {
mockBranchService.getDefaultBranch.mockResolvedValue({ name: 'Test' });
await expect(
service.getAvailabilities({
orderType: 'B2B-Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
}),
).rejects.toThrow('Default branch has no ID');
});
});
describe('Download availability', () => {
it('should fetch download availability with quantity 1', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Download',
items: [{ itemId: 123, ean: '1234567890' }],
});
const callArgs = mockAvailabilityService.AvailabilityShippingAvailability.mock.calls[0][0];
expect(callArgs[0].qty).toBe(1); // Always 1 for downloads
expect(result).toEqual({ '123': mockAvailabilityDTO });
});
it('should validate download availability (supplier 16 with 0 stock)', async () => {
const unavailableDownload: AvailabilityDTO = {
...mockAvailabilityDTO,
supplierId: 16,
qty: 0,
preferred: 1,
};
const mockResponse = {
error: null,
result: [unavailableDownload],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Download',
items: [{ itemId: 123, ean: '1234567890' }],
});
expect(result).toEqual({}); // Should be empty due to validation
});
it('should validate download availability (invalid status code)', async () => {
const invalidStatusDownload: AvailabilityDTO = {
...mockAvailabilityDTO,
status: 512 as any, // Invalid code for downloads (valid AvailabilityType but not for downloads)
preferred: 1,
};
const mockResponse = {
error: null,
result: [invalidStatusDownload],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Download',
items: [{ itemId: 123, ean: '1234567890' }],
});
expect(result).toEqual({}); // Should be empty due to validation
});
it('should accept valid download availability codes', async () => {
const validCodes: Array<2 | 32 | 256 | 1024 | 2048 | 4096> = [2, 32, 256, 1024, 2048, 4096];
for (const code of validCodes) {
const validDownload: AvailabilityDTO = {
...mockAvailabilityDTO,
status: code,
preferred: 1,
};
const mockResponse = {
error: null,
result: [validDownload],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Download',
items: [{ itemId: 123, ean: '1234567890' }],
});
expect(result).toHaveProperty('123');
}
});
});
describe('Abort signal support', () => {
it('should support abort signal cancellation', async () => {
const abortController = new AbortController();
const mockError = new Error('Request aborted');
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
throwError(() => mockError),
);
await expect(
service.getAvailabilities(
{
orderType: 'Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
},
abortController.signal,
),
).rejects.toThrow();
});
});
describe('Multiple items', () => {
it('should handle multiple items in single request', async () => {
const mockResponse = {
error: null,
result: [
{ ...mockAvailabilityDTO, itemId: 123, preferred: 1 },
{ ...mockAvailabilityDTO, itemId: 456, preferred: 1 },
],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Versand',
items: [
{ itemId: 123, ean: '1234567890', quantity: 1 },
{ itemId: 456, ean: '0987654321', quantity: 2 },
],
});
expect(Object.keys(result)).toHaveLength(2);
expect(result).toHaveProperty('123');
expect(result).toHaveProperty('456');
});
});
describe('Preferred availability selection', () => {
it('should select preferred availability when multiple options exist', async () => {
const nonPreferred: AvailabilityDTO = {
...mockAvailabilityDTO,
preferred: 0,
price: { value: { value: 25.0 } },
};
const preferred: AvailabilityDTO = {
...mockAvailabilityDTO,
preferred: 1,
price: { value: { value: 19.99 } },
};
const mockResponse = {
error: null,
result: [nonPreferred, preferred, { ...nonPreferred, preferred: 0 }],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailabilities({
orderType: 'Versand',
items: [{ itemId: 123, ean: '1234567890', quantity: 1 }],
});
expect(result['123'].preferred).toBe(1);
expect(result['123'].price?.value?.value).toBe(19.99);
});
});
describe('getAvailability', () => {
it('should fetch availability for a single item (InStore)', async () => {
const mockStock = { id: 'stock-123', name: 'Test Stock' };
const mockStockInfo = {
itemId: 123,
inStock: 5,
retailPrice: { value: { value: 19.99 } },
};
const mockSupplier = { id: 1, name: 'Supplier F' };
mockStockService.fetchStock.mockResolvedValue(mockStock);
mockStockService.fetchStockInfos.mockResolvedValue([mockStockInfo]);
mockSupplierService.getTakeAwaySupplier.mockResolvedValue(mockSupplier);
const result = await service.getAvailability({
orderType: 'Rücklage',
branchId: 42,
itemId: 123,
});
expect(mockStockService.fetchStock).toHaveBeenCalledWith(42, undefined);
expect(result).toBeDefined();
expect(result?.itemId).toBe(123);
});
it('should fetch availability for a single item (Delivery)', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailability({
orderType: 'Versand',
item: { itemId: 123, ean: '1234567890', quantity: 1 },
});
// Delivery order type filters out logisticianId and supplierId fields
// to prevent backend from auto-changing orderType to "DIG-Versand"
const expectedResult = { ...mockAvailabilityDTO };
delete expectedResult.logisticianId;
delete expectedResult.supplierId;
expect(result).toEqual(expectedResult);
});
it('should return undefined when item is not available', async () => {
const mockResponse = {
error: null,
result: [], // No availability
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailability({
orderType: 'Versand',
item: { itemId: 123, ean: '1234567890', quantity: 1 },
});
expect(result).toBeUndefined();
});
it('should throw error for invalid params', async () => {
await expect(
service.getAvailability({}),
).rejects.toThrow();
});
it('should support abort signal', async () => {
const abortController = new AbortController();
const mockError = new Error('Request aborted');
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
throwError(() => mockError),
);
await expect(
service.getAvailability(
{
orderType: 'Versand',
item: { itemId: 123, ean: '1234567890', quantity: 1 },
},
abortController.signal,
),
).rejects.toThrow();
});
it('should handle Download order type with single item', async () => {
const mockResponse = {
error: null,
result: [mockAvailabilityDTO],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailability({
orderType: 'Download',
item: { itemId: 123, ean: '1234567890' },
});
const callArgs = mockAvailabilityService.AvailabilityShippingAvailability.mock.calls[0][0];
expect(callArgs[0].qty).toBe(1); // Always 1 for downloads
expect(result).toEqual(mockAvailabilityDTO);
});
it('should handle B2B with single item and logistician override', async () => {
const mockLogistician = {
id: 5,
logisticianNumber: '2470',
name: 'DHL',
};
const mockBranch = {
id: 42,
name: 'Test Branch',
branchNumber: '001',
};
mockBranchService.getDefaultBranch.mockResolvedValue(mockBranch);
mockLogisticianService.LogisticianGetLogisticians.mockReturnValue(
of({
error: null,
result: [mockLogistician],
message: null,
invalidProperties: null,
}),
);
const mockResponse = {
error: null,
result: [{ ...mockAvailabilityDTO, logisticianId: 99 }],
message: null,
invalidProperties: null,
};
mockAvailabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
const result = await service.getAvailability({
orderType: 'B2B-Versand',
item: { itemId: 123, ean: '1234567890', quantity: 1 },
});
expect(mockBranchService.getDefaultBranch).toHaveBeenCalled();
expect(result?.logisticianId).toBe(5);
});
});
});

View File

@@ -0,0 +1,410 @@
import { inject, Injectable } from '@angular/core';
import { AvailabilityService as GeneratedAvailabilityService } from '@generated/swagger/availability-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { LogisticianService, Logistician } from '@isa/oms/data-access';
import { logger } from '@isa/core/logging';
// TODO: [Next Sprint - Architectural] Abstract cross-domain dependency
// Current: Direct dependency on remission domain (BranchService)
// Issue: availability domain cannot be used without remission domain
// Recommended approach:
// 1. Create abstract DefaultBranchProvider in availability domain
// 2. Inject provider instead of concrete BranchService
// 3. Implement RemissionBranchProvider at app level
// 4. Benefits: Domain independence, better testability, cleaner boundaries
// See: docs/architecture/domain-boundaries.md (if exists)
import {
BranchService,
RemissionStockService,
} from '@isa/remission/data-access';
import { SupplierService } from '@isa/checkout/data-access';
import {
GetAvailabilityParamsSchema,
GetAvailabilityInputParams,
GetInStoreAvailabilityParams,
GetPickupAvailabilityParams,
GetDeliveryAvailabilityParams,
GetDigDeliveryAvailabilityParams,
GetB2bDeliveryAvailabilityParams,
GetDownloadAvailabilityParams,
GetSingleItemAvailabilityParamsSchema,
GetSingleItemAvailabilityInputParams,
} from '../schemas';
import { Availability } from '../models';
import { AvailabilityRequestAdapter } from '../adapters/availability-request.adapter';
import {
transformAvailabilitiesToDictionary,
transformAvailabilitiesToDictionaryWithFieldFilter,
transformDownloadAvailabilitiesToDictionary,
transformStockToAvailability,
executeAvailabilityApiCall,
logAvailabilityResult,
convertSingleItemToBatchParams,
extractItemIdFromSingleParams,
} from '../helpers';
/**
* Service for checking product availability across multiple order types.
*
* Supports:
* - InStore (Rücklage): Branch-based in-store availability
* - Pickup (Abholung): Branch-based pickup availability
* - Delivery (Versand): Standard shipping availability
* - DIG-Versand: Digital shipping for webshop customers
* - B2B-Versand: Business-to-business shipping with logistician override
* - Download: Digital download availability
*/
@Injectable({ providedIn: 'root' })
export class AvailabilityService {
#stockService = inject(RemissionStockService);
#availabilityService = inject(GeneratedAvailabilityService);
#logisticianService = inject(LogisticianService);
#branchService = inject(BranchService);
#supplierService = inject(SupplierService);
#logger = logger(() => ({ service: 'AvailabilityService' }));
/**
* Checks availability for multiple items based on order type.
*
* @param params Availability parameters (will be validated with Zod)
* @param abortSignal Optional abort signal for request cancellation
* @returns Dictionary mapping itemId to Availability
*/
async getAvailabilities(
params: GetAvailabilityInputParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
// Validate params with Zod schema
const validated = GetAvailabilityParamsSchema.parse(params);
this.#logger.info('Checking availability', () => ({ params }));
// Route to appropriate handler based on order type
switch (validated.orderType) {
case 'Rücklage':
return this.#getInStoreAvailability(validated, abortSignal);
case 'Abholung':
return this.#getPickupAvailability(validated, abortSignal);
case 'Versand':
return this.#getDeliveryAvailability(validated, abortSignal);
case 'DIG-Versand':
return this.#getDigDeliveryAvailability(validated, abortSignal);
case 'B2B-Versand':
return this.#getB2bDeliveryAvailability(validated, abortSignal);
case 'Download':
return this.#getDownloadAvailability(validated, abortSignal);
default: {
const _exhaustive: never = validated;
throw new Error(
`Unsupported order type: ${JSON.stringify(_exhaustive)}`,
);
}
}
}
/**
* Checks availability for a single item.
*
* This is more practical than getAvailabilities when you only need to check one item,
* as it avoids array wrapping and dictionary extraction.
*
* @param params Single item availability parameters (will be validated with Zod)
* @param abortSignal Optional abort signal for request cancellation
* @returns Availability for the item, or undefined if not available
*/
async getAvailability(
params: GetSingleItemAvailabilityInputParams,
abortSignal?: AbortSignal,
): Promise<Availability | undefined> {
// Validate single-item params with Zod schema
const validated = GetSingleItemAvailabilityParamsSchema.parse(params);
this.#logger.info('Checking availability for single item', () => validated);
// Convert to batch format and call batch method
const batchParams = convertSingleItemToBatchParams(validated);
const results = await this.getAvailabilities(batchParams, abortSignal);
// Extract and return the single item result
const itemId = extractItemIdFromSingleParams(validated);
return results[itemId];
}
// TODO: [Service Refactoring - High Priority] Eliminate order type handler duplication
// Current: 6 nearly identical methods, 180+ lines duplicated (Complexity: 7/10)
// Target: Template Method + Strategy pattern with handler registry
//
// Proposed architecture:
// 1. Create AvailabilityHandler interface:
// - prepareRequest(params): AvailabilityRequestDTO[]
// - getEndpoint(service): Observable
// - requiresSpecialHandling(): boolean
// - postProcess?(availabilities): Promise<Dict<Availability>>
//
// 2. Implement concrete handlers:
// - StandardShippingHandler (Versand, DIG-Versand, B2B-Versand)
// - StoreAvailabilityHandler (Rücklage, Abholung)
// - B2bHandler (extends Store, adds logistician post-processing)
// - DownloadHandler (extends Standard, adds validation)
//
// 3. Registry pattern:
// - #handlers = new Map<OrderType, AvailabilityHandler>()
// - Single executeHandler() method with common workflow
//
// 4. Special cases use post-processing hook:
// - B2B: Override logisticianId
// - Download: Validate availability status
//
// Benefits:
// - Eliminates 180+ lines of duplication
// - Bug fixes apply to all order types automatically
// - Easy to add new order types (implement handler interface)
// - Clear separation: request prep → execution → post-processing
// - Single place for error handling and logging
//
// Effort: ~6 hours | Impact: High | Risk: Medium
// See: complexity-analysis.md (Code Review Section 2, Option 1)
/**
* InStore availability - uses store endpoint with branch context
*/
async #getInStoreAvailability(
params: GetInStoreAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
const stock = params.branchId
? await this.#stockService.fetchStock(params.branchId, abortSignal)
: undefined;
if (!stock?.id) {
this.#logger.warn(
'Branch has no stock ID, cannot fetch in-store availability',
() => ({ branchId: params.branchId }),
);
return {};
}
// Fetch supplier and stock info in parallel
const [supplier, stockInfos] = await Promise.all([
this.#supplierService.getTakeAwaySupplier(abortSignal),
this.#stockService.fetchStockInfos(
{ itemIds: params.itemsIds, stockId: stock.id },
abortSignal,
),
]);
return transformStockToAvailability(stockInfos, params.itemsIds, supplier);
}
/**
* Pickup availability - uses store endpoint with branch context
*/
async #getPickupAvailability(
params: GetPickupAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
const request = AvailabilityRequestAdapter.toPickupRequest(params);
const availabilities = await executeAvailabilityApiCall(
this.#availabilityService.AvailabilityStoreAvailability(request),
abortSignal,
{
orderType: 'Abholung',
itemIds: params.items.map((i) => i.itemId),
additional: { branchId: params.branchId },
},
);
const result = transformAvailabilitiesToDictionary(
(availabilities || []) as Availability[],
params.items,
);
logAvailabilityResult(
'Pickup',
params.items.length,
Object.keys(result).length,
);
return result;
}
/**
* Standard delivery availability - uses shipping endpoint
*
* Note: Uses special transformation that excludes supplier/logistician fields
* to prevent backend from auto-changing orderType to "DIG-Versand"
*/
async #getDeliveryAvailability(
params: GetDeliveryAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
const request = AvailabilityRequestAdapter.toDeliveryRequest(params);
const availabilities = await executeAvailabilityApiCall(
this.#availabilityService.AvailabilityShippingAvailability(request),
abortSignal,
{
orderType: 'Versand',
itemIds: params.items.map((i) => i.itemId),
},
);
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
(availabilities || []) as Availability[],
params.items,
);
logAvailabilityResult(
'Delivery',
params.items.length,
Object.keys(result).length,
);
return result;
}
/**
* DIG delivery availability - uses shipping endpoint (same as standard delivery)
*/
async #getDigDeliveryAvailability(
params: GetDigDeliveryAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
const request = AvailabilityRequestAdapter.toDigDeliveryRequest(params);
const availabilities = await executeAvailabilityApiCall(
this.#availabilityService.AvailabilityShippingAvailability(request),
abortSignal,
{
orderType: 'DIG-Versand',
itemIds: params.items.map((i) => i.itemId),
},
);
const result = transformAvailabilitiesToDictionary(
(availabilities || []) as Availability[],
params.items,
);
logAvailabilityResult(
'DIG-Versand',
params.items.length,
Object.keys(result).length,
);
return result;
}
/**
* B2B delivery availability - uses store endpoint with logistician override
*
* Special handling:
* - Fetches default branch automatically (no shopId required in params)
* - Fetches logistician '2470'
* - Uses store availability API (not shipping)
* - Overrides logistician in response
*/
async #getB2bDeliveryAvailability(
params: GetB2bDeliveryAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
// Fetch default branch and logistician in parallel
const [defaultBranch, logistician] = await Promise.all([
this.#branchService.getDefaultBranch(abortSignal),
this.#getLogistician2470(abortSignal),
]);
if (!defaultBranch?.id) {
const error = new Error('Default branch has no ID');
this.#logger.error('Failed to get default branch for B2B', error);
throw error;
}
const request = AvailabilityRequestAdapter.toB2bRequest(
params,
defaultBranch.id,
);
const apiAvailabilities = await executeAvailabilityApiCall(
this.#availabilityService.AvailabilityStoreAvailability(request),
abortSignal,
{
orderType: 'B2B-Versand',
itemIds: params.items.map((i) => i.itemId),
additional: { shopId: defaultBranch.id },
},
);
const result = transformAvailabilitiesToDictionary(
(apiAvailabilities || []) as Availability[],
params.items,
);
// Override logistician for all availabilities
if (logistician.id !== undefined) {
Object.values(result).forEach((availability) => {
if (availability) {
availability.logisticianId = logistician.id;
}
});
} else {
this.#logger.warn('Logistician 2470 has no ID, cannot override', () => ({
logistician,
}));
}
logAvailabilityResult(
'B2B-Versand',
params.items.length,
Object.keys(result).length,
{
shopId: defaultBranch.id,
logisticianId: logistician.id,
},
);
return result;
}
/**
* Download availability - uses shipping endpoint with quantity forced to 1
*
* Special validation:
* - Supplier ID 16 with 0 stock = unavailable
* - Must have valid availability type code
*/
async #getDownloadAvailability(
params: GetDownloadAvailabilityParams,
abortSignal?: AbortSignal,
): Promise<{ [itemId: string]: Availability }> {
const request = AvailabilityRequestAdapter.toDownloadRequest(params);
const availabilities = await executeAvailabilityApiCall(
this.#availabilityService.AvailabilityShippingAvailability(request),
abortSignal,
{
orderType: 'Download',
itemIds: params.items.map((i) => i.itemId),
},
);
const result = transformDownloadAvailabilitiesToDictionary(
(availabilities || []) as Availability[],
params.items,
);
logAvailabilityResult(
'Download',
params.items.length,
Object.keys(result).length,
);
return result;
}
/**
* Fetches logistician '2470' for B2B availability.
* Delegates to LogisticianService which handles caching.
*/
async #getLogistician2470(abortSignal?: AbortSignal): Promise<Logistician> {
return this.#logisticianService.getLogistician2470(abortSignal);
}
}

View File

@@ -0,0 +1 @@
export * from './availability.service';

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,33 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default
// @ts-expect-error - Vitest reporter tuple types have complex inference issues, but config works correctly at runtime
defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/availability/data-access',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: [
'default',
['junit', { outputFile: '../../../testresults/junit-availability-data-access.xml' }],
],
coverage: {
reportsDirectory: '../../../coverage/libs/availability/data-access',
provider: 'v8' as const,
reporter: ['text', 'cobertura'],
},
},
}));