mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1967: Reward Shopping Cart Implementation
This commit is contained in:
committed by
Nino Righi
parent
d761704dc4
commit
f15848d5c0
7
libs/availability/data-access/README.md
Normal file
7
libs/availability/data-access/README.md
Normal 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.
|
||||
34
libs/availability/data-access/eslint.config.cjs
Normal file
34
libs/availability/data-access/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
28
libs/availability/data-access/project.json
Normal file
28
libs/availability/data-access/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
libs/availability/data-access/src/index.ts
Normal file
6
libs/availability/data-access/src/index.ts
Normal 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';
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
2
libs/availability/data-access/src/lib/adapters/index.ts
Normal file
2
libs/availability/data-access/src/lib/adapters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './availability-request.adapter';
|
||||
export * from './get-availability-params.adapter';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
1
libs/availability/data-access/src/lib/facades/index.ts
Normal file
1
libs/availability/data-access/src/lib/facades/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './availability.facade';
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
4
libs/availability/data-access/src/lib/helpers/index.ts
Normal file
4
libs/availability/data-access/src/lib/helpers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './availability.helpers';
|
||||
export * from './availability-transformers';
|
||||
export * from './availability-api-helpers';
|
||||
export * from './single-to-batch-params';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
3
libs/availability/data-access/src/lib/models/index.ts
Normal file
3
libs/availability/data-access/src/lib/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './availability-type';
|
||||
export * from './availability';
|
||||
export * from './order-type';
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderType } from '@isa/checkout/data-access';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
1
libs/availability/data-access/src/lib/schemas/index.ts
Normal file
1
libs/availability/data-access/src/lib/schemas/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './get-availability-params.schema';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
1
libs/availability/data-access/src/lib/services/index.ts
Normal file
1
libs/availability/data-access/src/lib/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './availability.service';
|
||||
13
libs/availability/data-access/src/test-setup.ts
Normal file
13
libs/availability/data-access/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/availability/data-access/tsconfig.json
Normal file
30
libs/availability/data-access/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/availability/data-access/tsconfig.lib.json
Normal file
27
libs/availability/data-access/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/availability/data-access/tsconfig.spec.json
Normal file
29
libs/availability/data-access/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
33
libs/availability/data-access/vite.config.mts
Normal file
33
libs/availability/data-access/vite.config.mts
Normal 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'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user