Merged PR 1989: fix(checkout): resolve currency constraint violations in price handling

fix(checkout): resolve currency constraint violations in price handling

- Add ensureCurrencyDefaults() helper to normalize price objects with EUR defaults
- Fix currency constraint violation in shopping cart item additions (bug #5405)
- Apply price normalization across availability, checkout, and shopping cart services
- Update 8 locations: availability.adapter, checkout.service, shopping-cart.service,
  get-availability-params.adapter, availability-transformers, reward quantity control
- Refactor OrderType to @isa/common/data-access for cross-domain reusability
- Remove duplicate availability service from catalogue library
- Enhance PriceValue and VatValue schemas with proper currency defaults
- Add availability-transformers.spec.ts test coverage
- Fix QuantityControl fallback from 0 to 1 to prevent invalid state warnings

Resolves issue where POST requests to /checkout/v6/store/shoppingcart/{id}/item
were sending price objects without required currency/currencySymbol fields,
causing 400 Bad Request with 'Currency: Constraint violation: NotNull' error.

Related work items: #5405
This commit is contained in:
Lorenz Hilpert
2025-10-28 10:34:39 +00:00
committed by Nino Righi
parent 2d654aa63a
commit bfd151dd84
24 changed files with 1185 additions and 1170 deletions

View File

@@ -41,6 +41,7 @@ import { VATDTO } from '@generated/swagger/oms-api';
import { DomainCatalogService } from '@domain/catalog';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { Loyalty, OrderType, Promotion } from '@isa/checkout/data-access';
import { ensureCurrencyDefaults } from '@isa/common/data-access';
@Injectable()
export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
@@ -314,11 +315,17 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
const itemData = mapToItemData(item, this.type);
if ((purchaseOption === 'in-store' || purchaseOption === undefined) && !this.isOptionDisabled('in-store')) {
if (
(purchaseOption === 'in-store' || purchaseOption === undefined) &&
!this.isOptionDisabled('in-store')
) {
promises.push(this._loadInStoreAvailability(itemData));
}
if ((purchaseOption === 'pickup' || purchaseOption === undefined) && !this.isOptionDisabled('pickup')) {
if (
(purchaseOption === 'pickup' || purchaseOption === undefined) &&
!this.isOptionDisabled('pickup')
) {
promises.push(this._loadPickupAvailability(itemData));
}
@@ -1075,6 +1082,8 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
loyalty = { value: redemptionPoints };
// Set price to 0
price.value.value = 0;
price.value.currency = 'EUR';
price.value.currencySymbol = '€';
}
let destination: EntityDTOContainerOfDestinationDTO;
@@ -1122,6 +1131,8 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
// we need to make sure we don't update the price
if (this.useRedemptionPoints) {
price.value.value = 0;
price.value.currency = 'EUR';
price.value.currencySymbol = '€';
}
let destination: EntityDTOContainerOfDestinationDTO;

View File

@@ -3899,6 +3899,90 @@
"implicitDependencies": []
}
},
"format-name": {
"name": "format-name",
"type": "lib",
"data": {
"root": "libs/utils/format-name",
"targets": {
"eslint:lint": {
"cache": true,
"options": {
"cwd": "libs/utils/format-name",
"command": "eslint ."
},
"inputs": [
"default",
"^default",
"{workspaceRoot}/eslint.config.js",
"{workspaceRoot}/libs/utils/format-name/eslint.config.cjs",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint"
]
}
],
"outputs": [
"{options.outputFile}"
],
"metadata": {
"technologies": [
"eslint"
],
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0
}
}
}
},
"executor": "nx:run-commands",
"configurations": {},
"parallelism": true
},
"test": {
"executor": "@nx/vite:test",
"outputs": [
"{options.reportsDirectory}"
],
"options": {
"reportsDirectory": "../../../coverage/libs/utils/format-name"
},
"configurations": {},
"parallelism": true,
"cache": true,
"inputs": [
"default",
"^production"
]
},
"lint": {
"executor": "@nx/eslint:lint",
"configurations": {},
"options": {},
"parallelism": true,
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.js"
]
}
},
"name": "format-name",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/utils/format-name/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"implicitDependencies": []
}
},
"ui-input-controls": {
"name": "ui-input-controls",
"type": "lib",
@@ -5530,6 +5614,90 @@
"implicitDependencies": []
}
},
"ui-carousel": {
"name": "ui-carousel",
"type": "lib",
"data": {
"root": "libs/ui/carousel",
"targets": {
"eslint:lint": {
"cache": true,
"options": {
"cwd": "libs/ui/carousel",
"command": "eslint ."
},
"inputs": [
"default",
"^default",
"{workspaceRoot}/eslint.config.js",
"{workspaceRoot}/libs/ui/carousel/eslint.config.cjs",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint"
]
}
],
"outputs": [
"{options.outputFile}"
],
"metadata": {
"technologies": [
"eslint"
],
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0
}
}
}
},
"executor": "nx:run-commands",
"configurations": {},
"parallelism": true
},
"test": {
"executor": "@nx/vite:test",
"outputs": [
"{options.reportsDirectory}"
],
"options": {
"reportsDirectory": "../../../coverage/libs/ui/carousel"
},
"configurations": {},
"parallelism": true,
"cache": true,
"inputs": [
"default",
"^production"
]
},
"lint": {
"executor": "@nx/eslint:lint",
"configurations": {},
"options": {},
"parallelism": true,
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.js"
]
}
},
"name": "ui-carousel",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/carousel/src",
"prefix": "ui",
"projectType": "library",
"tags": [],
"implicitDependencies": []
}
},
"ui-buttons": {
"name": "ui-buttons",
"type": "lib",
@@ -6753,6 +6921,11 @@
"target": "ui-empty-state",
"type": "static"
},
{
"source": "remission-shared-search-item-to-remit-dialog",
"target": "utils-scroll-position",
"type": "static"
},
{
"source": "remission-shared-search-item-to-remit-dialog",
"target": "remission-shared-product",
@@ -6764,7 +6937,63 @@
"type": "static"
}
],
"checkout-feature-reward-order-confirmation": [],
"checkout-feature-reward-order-confirmation": [
{
"source": "checkout-feature-reward-order-confirmation",
"target": "shared-address",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "crm-data-access",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "oms-data-access",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "icons",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "ui-buttons",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "ui-input-controls",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "checkout-data-access",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "shared-product-image",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "shared-product-router-link",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "checkout-shared-product-info",
"type": "static"
},
{
"source": "checkout-feature-reward-order-confirmation",
"target": "core-tabs",
"type": "static"
}
],
"remission-return-receipt-actions": [
{
"source": "remission-return-receipt-actions",
@@ -6840,16 +7069,6 @@
}
],
"reward-selection-dialog": [
{
"source": "reward-selection-dialog",
"target": "shared-address",
"type": "static"
},
{
"source": "reward-selection-dialog",
"target": "checkout-data-access",
"type": "static"
},
{
"source": "reward-selection-dialog",
"target": "availability-data-access",
@@ -6860,6 +7079,11 @@
"target": "catalogue-data-access",
"type": "static"
},
{
"source": "reward-selection-dialog",
"target": "checkout-data-access",
"type": "static"
},
{
"source": "reward-selection-dialog",
"target": "core-logging",
@@ -6986,6 +7210,16 @@
"source": "checkout-feature-reward-shopping-cart",
"target": "reward-selection-dialog",
"type": "static"
},
{
"source": "checkout-feature-reward-shopping-cart",
"target": "oms-data-access",
"type": "static"
},
{
"source": "checkout-feature-reward-shopping-cart",
"target": "common-data-access",
"type": "static"
}
],
"remi-remission-list": [
@@ -7136,6 +7370,11 @@
"target": "crm-data-access",
"type": "static"
},
{
"source": "reward-catalog",
"target": "core-navigation",
"type": "static"
},
{
"source": "reward-catalog",
"target": "utils-scroll-position",
@@ -7146,6 +7385,11 @@
"target": "ui-skeleton-loader",
"type": "static"
},
{
"source": "reward-catalog",
"target": "format-name",
"type": "static"
},
{
"source": "reward-catalog",
"target": "ui-input-controls",
@@ -7199,6 +7443,11 @@
"target": "catalogue-data-access",
"type": "static"
},
{
"source": "checkout-shared-product-info",
"target": "shared-product-format",
"type": "static"
},
{
"source": "checkout-shared-product-info",
"target": "shared-product-image",
@@ -7209,11 +7458,6 @@
"target": "shared-product-router-link",
"type": "static"
},
{
"source": "checkout-shared-product-info",
"target": "shared-product-format",
"type": "static"
},
{
"source": "checkout-shared-product-info",
"target": "remission-data-access",
@@ -7253,6 +7497,11 @@
"target": "ui-buttons",
"type": "static"
},
{
"source": "oms-feature-return-details",
"target": "format-name",
"type": "static"
},
{
"source": "oms-feature-return-details",
"target": "ui-menu",
@@ -7456,6 +7705,11 @@
"target": "ui-tooltip",
"type": "static"
},
{
"source": "oms-feature-return-search",
"target": "format-name",
"type": "static"
},
{
"source": "oms-feature-return-search",
"target": "ui-item-rows",
@@ -7711,6 +7965,16 @@
"source": "utils-scroll-position",
"target": "core-storage",
"type": "static"
},
{
"source": "utils-scroll-position",
"target": "icons",
"type": "static"
},
{
"source": "utils-scroll-position",
"target": "ui-buttons",
"type": "static"
}
],
"checkout-data-access": [
@@ -7754,6 +8018,16 @@
"target": "common-data-access",
"type": "static"
},
{
"source": "checkout-data-access",
"target": "shared-address",
"type": "static"
},
{
"source": "checkout-data-access",
"target": "oms-data-access",
"type": "static"
},
{
"source": "checkout-data-access",
"target": "catalogue-data-access",
@@ -7774,11 +8048,6 @@
"target": "common-decorators",
"type": "static"
},
{
"source": "checkout-data-access",
"target": "oms-data-access",
"type": "static"
},
{
"source": "checkout-data-access",
"target": "remission-data-access",
@@ -7839,6 +8108,7 @@
"ui-skeleton-loader": [],
"utils-z-safe-parse": [],
"common-decorators": [],
"format-name": [],
"ui-input-controls": [
{
"source": "ui-input-controls",
@@ -7864,6 +8134,11 @@
}
],
"crm-data-access": [
{
"source": "crm-data-access",
"target": "generated-swagger-oms-api",
"type": "static"
},
{
"source": "crm-data-access",
"target": "generated-swagger-crm-api",
@@ -8094,6 +8369,18 @@
],
"ui-item-rows": [],
"core-config": [],
"ui-carousel": [
{
"source": "ui-carousel",
"target": "ui-buttons",
"type": "static"
},
{
"source": "ui-carousel",
"target": "icons",
"type": "static"
}
],
"ui-buttons": [
{
"source": "ui-buttons",
@@ -8336,6 +8623,11 @@
"target": "ui-buttons",
"type": "static"
},
{
"source": "isa-app",
"target": "ui-carousel",
"type": "static"
},
{
"source": "isa-app",
"target": "ui-datepicker",

View File

@@ -122,11 +122,6 @@ export class GetAvailabilityParamsAdapter {
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) {
@@ -205,8 +200,8 @@ export class GetAvailabilityParamsAdapter {
? {
value: item.availability.price.value ?? {
value: undefined,
currency: undefined,
currencySymbol: undefined,
currency: 'EUR',
currencySymbol: '€',
},
vat: item.availability.price.vat ?? {
value: undefined,

View File

@@ -86,40 +86,6 @@ export async function executeAvailabilityApiCall<T>(
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.
*

View File

@@ -0,0 +1,508 @@
import { describe, it, expect } from 'vitest';
import {
transformAvailabilitiesToDictionary,
transformAvailabilitiesToDictionaryWithFieldFilter,
transformDownloadAvailabilitiesToDictionary,
transformStockToAvailability,
} from './availability-transformers';
import { Availability, AvailabilityType } from '../models';
import { StockInfo } from '@isa/remission/data-access';
import { Supplier } from '@isa/checkout/data-access';
// ============================================================================
// Mock Data Helpers
// ============================================================================
function mockAvailability(
itemId: number,
overrides: Partial<Availability> = {},
): Availability {
return {
itemId,
status: AvailabilityType.Available,
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,
ssc: '1024',
sscText: 'Available',
...overrides,
};
}
function mockStockInfo(
itemId: number,
overrides: Partial<StockInfo> = {},
): StockInfo {
return {
itemId,
inStock: 10,
retailPrice: 19.99,
...overrides,
};
}
function mockSupplier(overrides: Partial<Supplier> = {}): Supplier {
return {
id: 'F',
name: 'Filiale',
...overrides,
};
}
// ============================================================================
// Tests: transformAvailabilitiesToDictionary
// ============================================================================
describe('transformAvailabilitiesToDictionary', () => {
it('should return empty dictionary for empty availabilities array', () => {
const result = transformAvailabilitiesToDictionary(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should return empty dictionary for empty requested items', () => {
const availabilities = [mockAvailability(123)];
const result = transformAvailabilitiesToDictionary(availabilities, []);
expect(result).toEqual({});
});
it('should skip items without itemId', () => {
const availabilities = [
mockAvailability(123),
mockAvailability(456),
];
const requestedItems = [
{ itemId: 123 },
{}, // Missing itemId
{ itemId: 456 },
];
const result = transformAvailabilitiesToDictionary(
availabilities,
requestedItems,
);
expect(result).toHaveProperty('123');
expect(result).toHaveProperty('456');
expect(Object.keys(result)).toHaveLength(2);
});
it('should transform single item correctly', () => {
const availability = mockAvailability(123, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should transform multiple items correctly', () => {
const availability1 = mockAvailability(123, { preferred: 1 });
const availability2 = mockAvailability(456, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability1, availability2],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result).toEqual({
'123': availability1,
'456': availability2,
});
});
it('should exclude item when no preferred availability found', () => {
const availability = mockAvailability(123, { preferred: 0 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should match itemIds correctly with type coercion', () => {
const availability = mockAvailability(123, { preferred: 1 });
const result = transformAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
// Key should be string, but matches number itemId
expect(result).toHaveProperty('123');
expect(result['123'].itemId).toBe(123);
});
});
// ============================================================================
// Tests: transformAvailabilitiesToDictionaryWithFieldFilter
// ============================================================================
describe('transformAvailabilitiesToDictionaryWithFieldFilter', () => {
it('should exclude supplierId field from result', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result['123']).toBeDefined();
expect(result['123']).not.toHaveProperty('supplierId');
});
it('should exclude all supplier and logistician fields', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
supplier: { id: 5, name: 'Test Supplier' },
logisticianId: 10,
logistician: { id: 10, name: 'Test Logistician' },
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
const transformed = result['123'];
expect(transformed).toBeDefined();
expect(transformed).not.toHaveProperty('supplierId');
expect(transformed).not.toHaveProperty('supplier');
expect(transformed).not.toHaveProperty('logisticianId');
expect(transformed).not.toHaveProperty('logistician');
});
it('should preserve all other availability fields', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 5,
qty: 10,
status: AvailabilityType.Available,
price: {
value: { value: 29.99, currency: 'EUR', currencySymbol: '€' },
vat: { value: 5.7, inPercent: 19, label: '19%', vatType: 1 },
},
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
const transformed = result['123'];
expect(transformed.itemId).toBe(123);
expect(transformed.qty).toBe(10);
expect(transformed.status).toBe(AvailabilityType.Available);
expect(transformed.price?.value?.value).toBe(29.99);
expect(transformed.at).toBe('2025-10-15');
});
it('should return empty dictionary for empty availabilities', () => {
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should skip items without itemId', () => {
const availabilities = [mockAvailability(123)];
const requestedItems = [{ itemId: 123 }, {}];
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
availabilities,
requestedItems,
);
expect(Object.keys(result)).toHaveLength(1);
expect(result).toHaveProperty('123');
});
it('should exclude item when no preferred availability', () => {
const availability = mockAvailability(123, { preferred: 0 });
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should filter fields for multiple items', () => {
const availability1 = mockAvailability(123, {
preferred: 1,
supplierId: 5,
});
const availability2 = mockAvailability(456, {
preferred: 1,
supplierId: 8,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability1, availability2],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result['123']).not.toHaveProperty('supplierId');
expect(result['456']).not.toHaveProperty('supplierId');
});
it('should handle availabilities without supplier fields gracefully', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: undefined,
supplier: undefined,
logisticianId: undefined,
logistician: undefined,
});
const result = transformAvailabilitiesToDictionaryWithFieldFilter(
[availability],
[{ itemId: 123 }],
);
expect(result['123']).toBeDefined();
expect(result['123'].itemId).toBe(123);
});
});
// ============================================================================
// Tests: transformDownloadAvailabilitiesToDictionary
// ============================================================================
describe('transformDownloadAvailabilitiesToDictionary', () => {
it('should include item with valid download availability', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 1,
qty: 5,
status: AvailabilityType.Available, // 1024
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should exclude supplier 16 with zero stock', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 16,
qty: 0,
status: AvailabilityType.Available,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should include supplier 16 with positive stock', () => {
const availability = mockAvailability(123, {
preferred: 1,
supplierId: 16,
qty: 5,
status: AvailabilityType.Available,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({ '123': availability });
});
it('should exclude item with invalid status code', () => {
const availability = mockAvailability(123, {
preferred: 1,
status: AvailabilityType.NotAvailable, // 1 (invalid for downloads)
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should accept all valid download status codes', () => {
const validStatusCodes = [
AvailabilityType.PrebookAtBuyer, // 2
AvailabilityType.PrebookAtRetailer, // 32
AvailabilityType.PrebookAtSupplier, // 256
AvailabilityType.Available, // 1024
AvailabilityType.OnDemand, // 2048
AvailabilityType.AtProductionDate, // 4096
];
validStatusCodes.forEach((status, index) => {
const availability = mockAvailability(100 + index, {
preferred: 1,
status,
supplierId: 1,
qty: 5,
});
const result = transformDownloadAvailabilitiesToDictionary(
[availability],
[{ itemId: 100 + index }],
);
expect(result).toHaveProperty(String(100 + index));
});
});
it('should return empty dictionary for empty availabilities', () => {
const result = transformDownloadAvailabilitiesToDictionary(
[],
[{ itemId: 123 }],
);
expect(result).toEqual({});
});
it('should include only valid downloads in mixed scenario', () => {
const validAvailability = mockAvailability(123, {
preferred: 1,
status: AvailabilityType.Available,
supplierId: 1,
});
const invalidAvailability = mockAvailability(456, {
preferred: 1,
status: AvailabilityType.Available,
supplierId: 16,
qty: 0, // Invalid: supplier 16 with zero stock
});
const result = transformDownloadAvailabilitiesToDictionary(
[validAvailability, invalidAvailability],
[{ itemId: 123 }, { itemId: 456 }],
);
expect(result).toHaveProperty('123');
expect(result).not.toHaveProperty('456');
});
});
// ============================================================================
// Tests: transformStockToAvailability
// ============================================================================
describe('transformStockToAvailability', () => {
it('should transform item with stock to available', () => {
const stock = mockStockInfo(123, {
inStock: 5,
retailPrice: 19.99,
});
const supplier = mockSupplier({ id: 'F', name: 'Filiale' });
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123']).toEqual({
status: AvailabilityType.Available,
itemId: 123,
qty: 5,
ssc: '999',
sscText: 'Filialentnahme',
supplierId: 'F',
price: 19.99,
});
});
it('should transform item without stock to not available', () => {
const stock = mockStockInfo(123, {
inStock: 0,
retailPrice: 19.99,
});
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123']).toEqual({
status: AvailabilityType.NotAvailable,
itemId: 123,
qty: 0,
ssc: '',
sscText: '',
supplierId: 'F',
price: 19.99,
});
});
it('should treat undefined inStock as zero', () => {
const stock = mockStockInfo(123, {
inStock: undefined,
retailPrice: 19.99,
});
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123'].qty).toBe(0);
expect(result['123'].status).toBe(AvailabilityType.NotAvailable);
});
it('should exclude item when stock info not found', () => {
const stock = mockStockInfo(123, { inStock: 5 });
const supplier = mockSupplier();
const result = transformStockToAvailability([stock], [456], supplier);
expect(result).toEqual({});
});
it('should transform multiple items correctly', () => {
const stocks = [
mockStockInfo(123, { inStock: 5, retailPrice: 19.99 }),
mockStockInfo(456, { inStock: 0, retailPrice: 29.99 }),
];
const supplier = mockSupplier();
const result = transformStockToAvailability(
stocks,
[123, 456],
supplier,
);
expect(result['123'].status).toBe(AvailabilityType.Available);
expect(result['123'].qty).toBe(5);
expect(result['456'].status).toBe(AvailabilityType.NotAvailable);
expect(result['456'].qty).toBe(0);
});
it('should map supplier id correctly', () => {
const stock = mockStockInfo(123, { inStock: 5 });
const supplier = mockSupplier({ id: 'TEST_ID', name: 'Test' });
const result = transformStockToAvailability([stock], [123], supplier);
expect(result['123'].supplierId).toBe('TEST_ID');
});
});

View File

@@ -1,6 +1,7 @@
import { Availability, AvailabilityType } from '../models';
import { StockInfo } from '@isa/remission/data-access';
import { Supplier } from '@isa/checkout/data-access';
import { ensureCurrencyDefaults } from '@isa/common/data-access';
import { selectPreferredAvailability, isDownloadAvailable } from './availability.helpers';
import { logger } from '@isa/core/logging';
@@ -209,7 +210,7 @@ export function transformStockToAvailability(
ssc: isAvailable ? '999' : '',
sscText: isAvailable ? 'Filialentnahme' : '',
supplierId: supplier?.id,
price: stockInfo.retailPrice,
price: ensureCurrencyDefaults(stockInfo.retailPrice),
} satisfies Availability;
}

View File

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

View File

@@ -37,7 +37,6 @@ import {
transformDownloadAvailabilitiesToDictionary,
transformStockToAvailability,
executeAvailabilityApiCall,
logAvailabilityResult,
convertSingleItemToBatchParams,
extractItemIdFromSingleParams,
} from '../helpers';
@@ -217,12 +216,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Pickup',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -252,12 +245,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Delivery',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -284,12 +271,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'DIG-Versand',
params.items.length,
Object.keys(result).length,
);
return result;
}
@@ -351,16 +332,6 @@ export class AvailabilityService {
}));
}
logAvailabilityResult(
'B2B-Versand',
params.items.length,
Object.keys(result).length,
{
shopId: defaultBranch.id,
logisticianId: logistician.id,
},
);
return result;
}
@@ -391,12 +362,6 @@ export class AvailabilityService {
params.items,
);
logAvailabilityResult(
'Download',
params.items.length,
Object.keys(result).length,
);
return result;
}

View File

@@ -1,10 +1,12 @@
# @isa/catalogue/data-access
A comprehensive product catalogue search and availability service for Angular applications, providing catalog item search, loyalty program integration, and specialized availability validation for download and delivery order types.
A comprehensive product catalogue search service for Angular applications, providing catalog item search and loyalty program integration.
## Overview
The Catalogue Data Access library provides a unified interface for searching and retrieving product catalog data, managing loyalty reward items, and validating product availability for digital downloads, DIG shipping, and B2B delivery. It integrates with the generated cat-search-api and availability-api clients to provide intelligent search routing, validation, and transformation of catalog and availability data.
The Catalogue Data Access library provides a unified interface for searching and retrieving product catalog data and managing loyalty reward items. It integrates with the generated cat-search-api client to provide intelligent search routing, validation, and transformation of catalog data.
**For availability checking:** Use `@isa/availability/data-access` which provides comprehensive availability validation across all order types (Download, DIG-Versand, B2B-Versand, etc.).
## Table of Contents
@@ -14,7 +16,6 @@ The Catalogue Data Access library provides a unified interface for searching and
- [API Reference](#api-reference)
- [Usage Examples](#usage-examples)
- [Search Types](#search-types)
- [Availability Validation](#availability-validation)
- [Validation and Business Rules](#validation-and-business-rules)
- [Error Handling](#error-handling)
- [Testing](#testing)
@@ -24,7 +25,6 @@ The Catalogue Data Access library provides a unified interface for searching and
- **Multi-type search support** - EAN, term-based, and loyalty item search
- **Loyalty program integration** - Specialized filtering and settings for reward items
- **Availability validation** - Download, DIG delivery, and B2B delivery validation
- **Zod validation** - Runtime schema validation for all parameters
- **Request cancellation** - AbortSignal support for all operations
- **Pagination support** - Skip/take parameters for search results
@@ -35,11 +35,11 @@ The Catalogue Data Access library provides a unified interface for searching and
## Quick Start
### 1. Import and Inject Services
### 1. Import and Inject Service
```typescript
import { Component, inject } from '@angular/core';
import { CatalougeSearchService, AvailabilityService } from '@isa/catalogue/data-access';
import { CatalougeSearchService } from '@isa/catalogue/data-access';
@Component({
selector: 'app-product-search',
@@ -47,10 +47,11 @@ import { CatalougeSearchService, AvailabilityService } from '@isa/catalogue/data
})
export class ProductSearchComponent {
#catalogueSearch = inject(CatalougeSearchService);
#availabilityService = inject(AvailabilityService);
}
```
**Note:** For availability checking, import `AvailabilityService` from `@isa/availability/data-access` instead.
### 2. Search Products by EAN
```typescript
@@ -108,24 +109,6 @@ async searchLoyaltyItems(): Promise<void> {
}
```
### 5. Validate Download Availability
```typescript
async validateDownloads(cartItems: ShoppingCartItem[]): Promise<void> {
const validations = await this.#availabilityService.validateDownloadAvailabilities(
cartItems
);
validations.forEach(validation => {
if (!validation.isAvailable) {
console.log(`Item ${validation.itemId} is not available for download`);
} else {
console.log(`Item ${validation.itemId} is available`);
// validation.availabilityUpdate contains the updated availability data
}
});
}
```
## Core Concepts
@@ -339,186 +322,6 @@ if (result) {
}
```
### AvailabilityService
Service for validating product availability for download and delivery order types.
**Note:** This service is focused on availability validation for the catalogue domain. For full availability checking across all order types, use `@isa/availability/data-access`.
#### `validateDownloadAvailabilities(items, abortSignal?): Promise<DownloadAvailabilityValidation[]>`
Validates download item availabilities and returns validation results.
**Parameters:**
- `items: ShoppingCartItem[]` - Shopping cart items to validate
- `abortSignal?: AbortSignal` - Optional abort signal for request cancellation
**Returns:** Promise resolving to array of validation results
**Business Rules:**
- Only processes items with `orderType === 'Download'`
- Skips items that already have `lastRequest` (already validated)
- Validates against download-specific status codes: 2, 32, 256, 1024, 2048, 4096
- Rejects supplier 16 with 0 stock
**Example:**
```typescript
const cartItems: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456', catalogProductNumber: 789 },
availability: { price: 9.99 }
}
}
];
const validations = await service.validateDownloadAvailabilities(cartItems);
validations.forEach(validation => {
if (validation.isAvailable) {
console.log(`Item ${validation.itemId} available for download`);
// Update cart with validation.availabilityUpdate
} else {
console.log(`Item ${validation.itemId} not available`);
// Remove from cart or mark as unavailable
}
});
```
**Validation Result Structure:**
```typescript
interface DownloadAvailabilityValidation {
itemId: number;
isAvailable: boolean;
availabilityUpdate?: {
availabilityType: number;
ssc: number;
sscText: string;
supplier: { id: number };
isPrebooked: boolean;
estimatedShippingDate: string;
price: number;
lastRequest: string; // ISO timestamp
};
}
```
#### `getDigDeliveryAvailability(item, abortSignal?): Promise<any>`
Gets DIG delivery availability for a shopping cart item.
**Parameters:**
- `item: ShoppingCartItem` - Shopping cart item
- `abortSignal?: AbortSignal` - Optional abort signal for request cancellation
**Returns:** Promise resolving to availability data or null if not available
**Example:**
```typescript
const item: ShoppingCartItem = {
id: 1,
data: {
product: { ean: '123456', catalogProductNumber: 789 },
quantity: 2,
availability: { price: 15.99 }
}
};
const availability = await service.getDigDeliveryAvailability(item);
if (availability) {
console.log(`Available: ${availability.sscText}`);
console.log(`Delivery: ${availability.estimatedShippingDate}`);
console.log(`Supplier: ${availability.supplier.id}`);
console.log(`Logistician: ${availability.logistician.id}`);
}
```
**Response Structure:**
```typescript
{
availabilityType: number;
ssc: number;
sscText: string;
supplier: { id: number };
isPrebooked: boolean;
estimatedShippingDate: string;
estimatedDelivery: string;
price: number;
logistician: { id: number };
supplierProductNumber: string;
supplierInfo: string;
lastRequest: string;
priceMaintained: boolean;
}
```
#### `getB2bDeliveryAvailability(item, defaultBranch, logistician, abortSignal?): Promise<any>`
Gets B2B delivery availability for a shopping cart item with branch and logistician context.
**Parameters:**
- `item: ShoppingCartItem` - Shopping cart item
- `defaultBranch: Branch` - Default branch for stock lookup
- `logistician: Logistician` - Logistician data (typically ID 2470)
- `abortSignal?: AbortSignal` - Optional abort signal for request cancellation
**Returns:** Promise resolving to availability data or null if not available
**Special Handling:**
- Uses store availability endpoint (not shipping)
- Calculates total stock across all availabilities
- Always uses provided logistician ID in response
**Example:**
```typescript
const item: ShoppingCartItem = {
id: 1,
data: {
product: { ean: '123456', catalogProductNumber: 789 },
quantity: 10,
availability: { price: 99.99 }
}
};
const branch = { id: 42, name: 'Main Branch' };
const logistician = { id: 2470, name: 'Standard Logistician' };
const availability = await service.getB2bDeliveryAvailability(
item,
branch,
logistician
);
if (availability) {
console.log(`In stock: ${availability.inStock} units`);
console.log(`Order deadline: ${availability.orderDeadline}`);
console.log(`Logistician: ${availability.logistician.id}`);
}
```
**Response Structure:**
```typescript
{
orderDeadline: string;
availabilityType: number;
ssc: number;
sscText: string;
supplier: { id: number };
isPrebooked: boolean;
estimatedShippingDate: string;
price: number;
inStock: number; // Total across all availabilities
supplierProductNumber: string;
supplierInfo: string;
lastRequest: string;
priceMaintained: boolean;
logistician: { id: number };
}
```
### Schema Types
#### SearchByTerm

View File

@@ -1,429 +0,0 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import {
AvailabilityService,
DownloadAvailabilityValidation,
ShoppingCartItem,
Branch,
Logistician,
} from './availability.service';
import { AvailabilityService as GeneratedAvailabilityService } from '@generated/swagger/availability-api';
import { of, NEVER } from 'rxjs';
describe('AvailabilityService', () => {
let spectator: SpectatorService<AvailabilityService>;
const createService = createServiceFactory({
service: AvailabilityService,
mocks: [GeneratedAvailabilityService],
});
beforeEach(() => {
spectator = createService();
});
describe('validateDownloadAvailabilities', () => {
it('should return empty array if no download items', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{ id: 1, data: { features: { orderType: 'DIG-Versand' } } },
];
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([]);
});
it('should skip items that already have lastRequest', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
availability: { lastRequest: '2024-01-01' },
},
},
];
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([]);
});
it('should mark item as unavailable if no product data', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{ id: 1, data: { features: { orderType: 'Download' } } },
];
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([{ itemId: 1, isAvailable: false }]);
});
it('should mark item as unavailable if API returns error', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456' },
},
},
];
const mockResponse = { error: true, result: undefined };
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([{ itemId: 1, isAvailable: false }]);
});
it('should mark item as unavailable if not in valid status codes', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456' },
},
},
];
const mockResponse: any = {
error: false,
result: [{ preferred: 1, status: 999 }], // Invalid status code
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([{ itemId: 1, isAvailable: false }]);
});
it('should return availability update for valid download item', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456', catalogProductNumber: 789 },
availability: { price: 10.99 },
},
},
];
const mockResponse: any = {
error: false,
result: [
{
preferred: 1,
status: 2, // Valid code
ssc: 1,
sscText: 'Available',
supplierId: 5,
isPrebooked: false,
requestStatusCode: '01',
at: '2024-12-25',
altAt: '2024-12-30',
price: 10.99,
},
],
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toHaveLength(1);
expect(result[0].isAvailable).toBe(true);
expect(result[0].availabilityUpdate).toBeDefined();
expect(result[0].availabilityUpdate?.availabilityType).toBe(2);
});
});
describe('getDigDeliveryAvailability', () => {
it('should return null if item has no product data', async () => {
// Arrange
const item: ShoppingCartItem = { id: 1, data: {} };
// Act
const result = await spectator.service.getDigDeliveryAvailability(item);
// Assert
expect(result).toBeNull();
});
it('should return null if API returns error', async () => {
// Arrange
const item: ShoppingCartItem = {
id: 1,
data: { product: { ean: '123456' } },
};
const mockResponse = { error: true, result: undefined };
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.getDigDeliveryAvailability(item);
// Assert
expect(result).toBeNull();
});
it('should return null if no preferred availability', async () => {
// Arrange
const item: ShoppingCartItem = {
id: 1,
data: { product: { ean: '123456' } },
};
const mockResponse: any = {
error: false,
result: [{ preferred: 0, status: 2 }],
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.getDigDeliveryAvailability(item);
// Assert
expect(result).toBeNull();
});
it('should return availability data for valid request', async () => {
// Arrange
const item: ShoppingCartItem = {
id: 1,
data: {
product: { ean: '123456', catalogProductNumber: 789 },
quantity: 2,
availability: { price: 15.99 },
},
};
const mockResponse: any = {
error: false,
result: [
{
preferred: 1,
status: 2,
ssc: 1,
sscText: 'Available',
supplierId: 5,
isPrebooked: false,
requestStatusCode: '01',
at: '2024-12-25',
altAt: '2024-12-30',
price: 15.99,
logisticianId: 100,
supplierProductNumber: 'SP123',
estimatedDelivery: '2024-12-26',
requested: '2024-12-20',
priceMaintained: true,
},
],
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.getDigDeliveryAvailability(item);
// Assert
expect(result).toBeDefined();
expect(result.availabilityType).toBe(2);
expect(result.ssc).toBe(1);
expect(result.supplier.id).toBe(5);
expect(result.logistician.id).toBe(100);
});
});
describe('getB2bDeliveryAvailability', () => {
const mockBranch: Branch = { id: 42, name: 'Test Branch' };
const mockLogistician: Logistician = { id: 2470, name: 'Test Logistician' };
it('should return null if item has no product data', async () => {
// Arrange
const item: ShoppingCartItem = { id: 1, data: {} };
// Act
const result = await spectator.service.getB2bDeliveryAvailability(
item,
mockBranch,
mockLogistician,
);
// Assert
expect(result).toBeNull();
});
it('should return null if API returns error', async () => {
// Arrange
const item: ShoppingCartItem = {
id: 1,
data: { product: { ean: '123456' } },
};
const mockResponse = { error: true, result: undefined };
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.getB2bDeliveryAvailability(
item,
mockBranch,
mockLogistician,
);
// Assert
expect(result).toBeNull();
});
it('should return availability data with total stock for valid request', async () => {
// Arrange
const item: ShoppingCartItem = {
id: 1,
data: {
product: { ean: '123456', catalogProductNumber: 789 },
quantity: 3,
availability: { price: 20.99 },
},
};
const mockResponse: any = {
error: false,
result: [
{
preferred: 1,
status: 2,
ssc: 1,
sscText: 'Available',
supplierId: 5,
isPrebooked: false,
requestStatusCode: '01',
at: '2024-12-25',
altAt: '2024-12-30',
price: 20.99,
qty: 10,
orderDeadline: '2024-12-24',
supplierProductNumber: 'SP456',
requested: '2024-12-20',
priceMaintained: true,
},
{ preferred: 0, qty: 5 },
{ preferred: 0, qty: 3 },
],
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityStoreAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.getB2bDeliveryAvailability(
item,
mockBranch,
mockLogistician,
);
// Assert
expect(result).toBeDefined();
expect(result.availabilityType).toBe(2);
expect(result.inStock).toBe(18); // 10 + 5 + 3
expect(result.logistician.id).toBe(2470);
expect(result.orderDeadline).toBe('2024-12-24');
});
});
describe('isDownloadAvailable (private method tested via validateDownloadAvailabilities)', () => {
it('should reject supplier 16 with 0 in stock', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456' },
},
},
];
const mockResponse: any = {
error: false,
result: [
{
preferred: 1,
status: 2, // Valid code
supplierId: 16,
inStock: 0, // Should reject
},
],
};
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(
of(mockResponse),
);
// Act
const result = await spectator.service.validateDownloadAvailabilities(items);
// Assert
expect(result).toEqual([{ itemId: 1, isAvailable: false }]);
});
});
describe('abortSignal handling', () => {
it('should handle abortSignal in validateDownloadAvailabilities', async () => {
// Arrange
const items: ShoppingCartItem[] = [
{
id: 1,
data: {
features: { orderType: 'Download' },
product: { ean: '123456' },
},
},
];
const availabilityService = spectator.inject(GeneratedAvailabilityService);
availabilityService.AvailabilityShippingAvailability.mockReturnValue(NEVER);
const abortController = new AbortController();
// Act
const promise = spectator.service.validateDownloadAvailabilities(
items,
abortController.signal,
);
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100),
);
// Assert
await expect(Promise.race([promise, timeout])).rejects.toThrow('Timeout');
});
});
});

View File

@@ -1,306 +0,0 @@
import { inject, Injectable } from '@angular/core';
import {
AvailabilityService as GeneratedAvailabilityService,
AvailabilityRequestDTO,
} from '@generated/swagger/availability-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { firstValueFrom } from 'rxjs';
/**
* Availability validation result for downloads
*/
export interface DownloadAvailabilityValidation {
/** Item ID */
itemId: number;
/** Whether item is available */
isAvailable: boolean;
/** Availability data to update (if available) */
availabilityUpdate?: {
availabilityType: number;
ssc: number;
sscText: string;
supplier: { id: number };
isPrebooked: boolean;
estimatedShippingDate: string;
price: number;
lastRequest: string;
};
}
/**
* Shopping cart item interface (minimal subset needed for availability operations)
*/
export interface ShoppingCartItem {
id: number;
data?: {
product?: {
ean: string;
catalogProductNumber?: number;
};
quantity?: number;
availability?: {
price?: number;
lastRequest?: string;
};
features?: Record<string, any>;
};
}
/**
* Branch data interface
*/
export interface Branch {
id: number;
[key: string]: any;
}
/**
* Logistician data interface
*/
export interface Logistician {
id: number;
[key: string]: any;
}
/**
* Service for availability validation and retrieval.
*
* @remarks
* This service handles availability operations for the catalogue domain:
* - Download item availability validation
* - DIG-Versand availability retrieval
* - B2B-Versand availability retrieval with branch and logistician context
*/
@Injectable({ providedIn: 'root' })
export class AvailabilityService {
#logger = logger(() => ({ service: 'AvailabilityService' }));
readonly #availabilityService = inject(GeneratedAvailabilityService);
/**
* Validates download item availabilities.
*
* @param items - Shopping cart items to validate
* @param abortSignal - Optional signal to abort the operation
* @returns Array of validation results with unavailable item IDs and update data
*/
async validateDownloadAvailabilities(
items: ShoppingCartItem[],
abortSignal?: AbortSignal,
): Promise<DownloadAvailabilityValidation[]> {
const downloadItems = items.filter(
(item) =>
item.data?.features?.['orderType'] === 'Download' &&
!item.data?.availability?.lastRequest,
);
if (downloadItems.length === 0) return [];
const validations: DownloadAvailabilityValidation[] = [];
for (const item of downloadItems) {
if (!item.data?.product) {
validations.push({ itemId: item.id, isAvailable: false });
continue;
}
const request: AvailabilityRequestDTO = {
ean: item.data.product.ean,
itemId: item.data.product.catalogProductNumber
? String(item.data.product.catalogProductNumber)
: undefined,
price: item.data.availability?.price as any,
qty: 1,
};
let req$ = this.#availabilityService.AvailabilityShippingAvailability([
request,
]);
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
validations.push({ itemId: item.id, isAvailable: false });
continue;
}
const availabilities = res.result || [];
const preferred = availabilities.find((a) => a.preferred === 1);
const isAvailable = this.isDownloadAvailable(preferred);
if (!isAvailable) {
validations.push({ itemId: item.id, isAvailable: false });
} else if (preferred) {
validations.push({
itemId: item.id,
isAvailable: true,
availabilityUpdate: {
availabilityType: preferred.status as any,
ssc: preferred.ssc as any,
sscText: preferred.sscText as any,
supplier: { id: preferred.supplierId as any },
isPrebooked: preferred.isPrebooked as any,
estimatedShippingDate:
preferred.requestStatusCode === '32'
? (preferred.altAt as any)
: (preferred.at as any),
price: preferred.price as any,
lastRequest: new Date().toISOString(),
},
});
}
}
return validations;
}
/**
* Gets DIG delivery availability for an item.
*
* @param item - Shopping cart item
* @param abortSignal - Optional signal to abort the operation
* @returns Availability data or null if not available
*/
async getDigDeliveryAvailability(
item: ShoppingCartItem,
abortSignal?: AbortSignal,
): Promise<any> {
if (!item.data?.product) return null;
const request: AvailabilityRequestDTO = {
ean: item.data.product.ean,
itemId: item.data.product.catalogProductNumber
? String(item.data.product.catalogProductNumber)
: undefined,
price: item.data.availability?.price as any,
qty: item.data.quantity || 1,
};
let req$ = this.#availabilityService.AvailabilityShippingAvailability([
request,
]);
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error('Failed to get DIG delivery availability', {
itemId: item.id,
});
return null;
}
const availabilities = res.result || [];
const preferred = availabilities.find((a) => a.preferred === 1);
if (!preferred) return null;
return {
availabilityType: preferred.status,
ssc: preferred.ssc,
sscText: preferred.sscText,
supplier: { id: preferred.supplierId },
isPrebooked: preferred.isPrebooked,
estimatedShippingDate:
preferred.requestStatusCode === '32' ? preferred.altAt : preferred.at,
estimatedDelivery: preferred.estimatedDelivery,
price: preferred.price,
logistician: { id: preferred.logisticianId },
supplierProductNumber: preferred.supplierProductNumber,
supplierInfo: preferred.requestStatusCode,
lastRequest: preferred.requested,
priceMaintained: preferred.priceMaintained,
};
}
/**
* Gets B2B delivery availability for an item.
*
* @param item - Shopping cart item
* @param defaultBranch - The default branch for stock lookup
* @param logistician - The logistician data
* @param abortSignal - Optional signal to abort the operation
* @returns Availability data or null if not available
*/
async getB2bDeliveryAvailability(
item: ShoppingCartItem,
defaultBranch: Branch,
logistician: Logistician,
abortSignal?: AbortSignal,
): Promise<any> {
if (!item.data?.product) return null;
const request: AvailabilityRequestDTO = {
ean: item.data.product.ean,
itemId: item.data.product.catalogProductNumber
? String(item.data.product.catalogProductNumber)
: undefined,
price: item.data.availability?.price as any,
qty: item.data.quantity || 1,
shopId: defaultBranch.id,
};
let req$ = this.#availabilityService.AvailabilityStoreAvailability([
request,
]);
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error('Failed to get B2B delivery availability', {
itemId: item.id,
});
return null;
}
const availabilities = res.result || [];
const preferred = availabilities.find((a) => a.preferred === 1);
if (!preferred) return null;
const totalAvailable = availabilities.reduce(
(sum, av) => sum + (av?.qty || 0),
0,
);
return {
orderDeadline: preferred.orderDeadline,
availabilityType: preferred.status,
ssc: preferred.ssc,
sscText: preferred.sscText,
supplier: { id: preferred.supplierId },
isPrebooked: preferred.isPrebooked,
estimatedShippingDate:
preferred.requestStatusCode === '32' ? preferred.altAt : preferred.at,
price: preferred.price,
inStock: totalAvailable,
supplierProductNumber: preferred.supplierProductNumber,
supplierInfo: preferred.requestStatusCode,
lastRequest: preferred.requested,
priceMaintained: preferred.priceMaintained,
logistician: { id: logistician.id },
};
}
/**
* Checks if download availability is valid.
* Based on the original domain service logic.
*
* @param availability - The availability data to check
* @returns True if available, false otherwise
*/
private isDownloadAvailable(availability: any): boolean {
if (!availability) return false;
// Check if supplier is 16 with 0 in stock
if (availability.supplierId === 16 && availability.inStock === 0) {
return false;
}
// Valid availability type codes: 2, 32, 256, 1024, 2048, 4096
const validCodes = [2, 32, 256, 1024, 2048, 4096];
return validCodes.includes(availability.status);
}
}

View File

@@ -1,2 +1 @@
export * from './availability.service';
export * from './catalouge-search.service';

View File

@@ -1,5 +1,6 @@
import { PriceDTO, Price } from '@generated/swagger/checkout-api';
import { Availability as AvaAvailability } from '@isa/availability/data-access';
import { ensureCurrencyDefaults } from '@isa/common/data-access';
import { Availability, AvailabilityType } from '../schemas';
/**
@@ -65,6 +66,8 @@ export class AvailabilityAdapter {
price: {
value: {
value: originalPrice ?? catalogueAvailability.price,
currency: 'EUR',
currencySymbol: '€',
},
},
};
@@ -119,7 +122,7 @@ export class AvailabilityAdapter {
ssc: availability.ssc,
sscText: availability.sscText,
isPrebooked: availability.isPrebooked,
price: availability.price,
price: ensureCurrencyDefaults(availability.price),
estimatedShippingDate: availability.at,
lastRequest: availability.requested,
};

View File

@@ -1,10 +1 @@
export const OrderType = {
InStore: 'Rücklage',
Pickup: 'Abholung',
Delivery: 'Versand',
DigitalShipping: 'DIG-Versand',
B2BShipping: 'B2B-Versand',
Download: 'Download',
} as const;
export type OrderType = (typeof OrderType)[keyof typeof OrderType];
export { OrderType } from '@isa/common/data-access';

View File

@@ -11,12 +11,8 @@ import {
StoreCheckoutPayerService,
StoreCheckoutPaymentService,
} from '@generated/swagger/checkout-api';
import {
OrderCreationService,
LogisticianService,
} from '@isa/oms/data-access';
import { AvailabilityService } from '@isa/catalogue/data-access';
import { BranchService } from '@isa/remission/data-access';
import { OrderCreationService } from '@isa/oms/data-access';
import { AvailabilityService } from '@isa/availability/data-access';
import { CheckoutCompletionError } from '../errors';
import { CompleteCheckoutParams } from '../schemas';
import {
@@ -42,8 +38,6 @@ describe('CheckoutService', () => {
let mockPayerService: any;
let mockPaymentService: any;
let mockAvailabilityService: any;
let mockBranchService: any;
let mockLogisticianService: any;
// Test fixtures
const createMockShoppingCartItem = (
@@ -168,17 +162,8 @@ describe('CheckoutService', () => {
};
mockAvailabilityService = {
validateDownloadAvailabilities: vi.fn(),
getDigDeliveryAvailability: vi.fn(),
getB2bDeliveryAvailability: vi.fn(),
};
mockBranchService = {
getDefaultBranch: vi.fn(),
};
mockLogisticianService = {
getAllLogisticians: vi.fn(),
getAvailabilities: vi.fn(),
getAvailability: vi.fn(),
};
// Configure TestBed
@@ -196,8 +181,6 @@ describe('CheckoutService', () => {
{ provide: StoreCheckoutPayerService, useValue: mockPayerService },
{ provide: StoreCheckoutPaymentService, useValue: mockPaymentService },
{ provide: AvailabilityService, useValue: mockAvailabilityService },
{ provide: BranchService, useValue: mockBranchService },
{ provide: LogisticianService, useValue: mockLogisticianService },
provideLogging({ level: LogLevel.Off }),
],
});
@@ -221,9 +204,24 @@ describe('CheckoutService', () => {
mockStoreCheckoutService.StoreCheckoutCreateOrRefreshCheckout.mockReturnValue(
of({ result: checkout, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
);
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
);
@@ -276,8 +274,26 @@ describe('CheckoutService', () => {
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer.mockReturnValue(
of({ result: {}, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability.mockReturnValue(
of({ result: shoppingCart, error: null }),
);
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
@@ -335,9 +351,24 @@ describe('CheckoutService', () => {
mockStoreCheckoutService.StoreCheckoutCreateOrRefreshCheckout.mockReturnValue(
of({ result: checkout, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
);
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
);
@@ -385,9 +416,24 @@ describe('CheckoutService', () => {
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem.mockReturnValue(
of({ result: shoppingCart, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
);
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
);
@@ -442,8 +488,26 @@ describe('CheckoutService', () => {
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer.mockReturnValue(
of({ result: {}, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability.mockReturnValue(
of({ result: shoppingCart, error: null }),
);
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
@@ -531,9 +595,24 @@ describe('CheckoutService', () => {
mockStoreCheckoutService.StoreCheckoutCreateOrRefreshCheckout.mockReturnValue(
of({ result: checkout, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
);
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
);
@@ -598,8 +677,26 @@ describe('CheckoutService', () => {
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer.mockReturnValue(
of({ result: {}, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability.mockReturnValue(
of({ result: shoppingCart, error: null }),
);
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),
@@ -655,8 +752,26 @@ describe('CheckoutService', () => {
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartSetLogisticianOnDestinationsByBuyer.mockReturnValue(
of({ result: {}, error: null }),
);
mockAvailabilityService.validateDownloadAvailabilities.mockResolvedValue(
[],
// Mock availability response with available status (code 1024 = Available)
mockAvailabilityService.getAvailabilities.mockImplementation((params) => {
const result: Record<string, any> = {};
if (params.items) {
params.items.forEach((item: any) => {
result[item.itemId.toString()] = {
status: 1024, // Available status code
ssc: '1024',
sscText: 'Available',
supplierId: 1,
at: '2024-01-01',
requested: new Date().toISOString(),
isPrebooked: false,
};
});
}
return Promise.resolve(result);
});
mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability.mockReturnValue(
of({ result: shoppingCart, error: null }),
);
mockBuyerService.StoreCheckoutBuyerSetBuyerPOST.mockReturnValue(
of({ result: checkout, error: null }),

View File

@@ -37,16 +37,12 @@ import {
ShoppingCartItem,
} from '../models';
import { CheckoutCompletionError } from '../errors';
import { LogisticianService } from '@isa/oms/data-access';
import { AvailabilityService } from '@isa/catalogue/data-access';
import { BranchService } from '@isa/remission/data-access';
import {
ShoppingCartItemAdapter,
AvailabilityAdapter,
BranchAdapter,
LogisticianAdapter,
CatalogueAvailabilityResponse,
} from '../adapters';
AvailabilityService,
OrderType,
Availability as AvailabilityModel,
} from '@isa/availability/data-access';
import { AvailabilityAdapter } from '../adapters';
import {
analyzeOrderOptions,
analyzeCustomerTypes,
@@ -86,9 +82,7 @@ export class CheckoutService {
// Domain data-access services
#shoppingCartDataService = inject(ShoppingCartService);
#logisticianService = inject(LogisticianService);
#availabilityService = inject(AvailabilityService);
#branchService = inject(BranchService);
/**
* Completes the checkout process.
@@ -238,7 +232,12 @@ export class CheckoutService {
// Step 13: Update destination shipping addresses (if delivery)
// Refresh checkout only when we need the destinations data
if (orderOptions.hasDelivery && validated.shippingAddress) {
if (
(orderOptions.hasDelivery ||
orderOptions.hasDigDelivery ||
orderOptions.hasB2BDelivery) &&
validated.shippingAddress
) {
this.#logger.debug('Refreshing checkout to get destinations');
const checkout = await this.refreshCheckout(
validated.shoppingCartId,
@@ -249,7 +248,7 @@ export class CheckoutService {
await this.updateDestinationShippingAddresses(
checkoutId,
checkout,
validated.shippingAddress as unknown as ShippingAddress,
validated.shippingAddress,
abortSignal,
);
}
@@ -426,48 +425,75 @@ export class CheckoutService {
items: EntityContainer<ShoppingCartItem>[],
abortSignal?: AbortSignal,
): Promise<void> {
// Convert checkout-api items to catalogue-api format using adapter
const catalogueItems = ShoppingCartItemAdapter.toCatalogueFormatBulk(items);
// Filter download items only
const downloadItems = items.filter(
(item) => item.data?.features?.['orderType'] === 'Download',
);
const validationResults =
await this.#availabilityService.validateDownloadAvailabilities(
catalogueItems,
if (downloadItems.length === 0) {
return;
}
// Transform to availability service format
const availabilityItems = downloadItems
.filter((item) => item.id && item.data?.product?.ean)
.map((item) => ({
itemId: item.id!,
ean: item.data!.product!.ean!,
price: item.data?.availability?.price,
}));
if (availabilityItems.length === 0) {
return;
}
// Call new availability service
const availabilitiesDict =
await this.#availabilityService.getAvailabilities(
{
orderType: OrderType.Download,
items: availabilityItems,
},
abortSignal,
);
const unavailableItemIds: number[] = [];
// Process validation results and update cart items
for (const result of validationResults) {
if (result.availabilityUpdate) {
// Convert catalogue availability to checkout format using adapter
const availabilityDTO = AvailabilityAdapter.toCheckoutFormat(
result.availabilityUpdate as CatalogueAvailabilityResponse,
);
// Process availability results and update cart items
for (const item of downloadItems) {
if (!item.id) continue;
const availability = availabilitiesDict[item.id.toString()];
if (availability) {
// Item is available (already validated by new service)
// Convert availability-api format to checkout format using adapter
const availabilityDTO =
AvailabilityAdapter.fromAvailabilityApi(availability);
let updateReq$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItemAvailability(
{
shoppingCartId,
shoppingCartItemId: result.itemId,
shoppingCartItemId: item.id,
availability: availabilityDTO,
},
);
if (abortSignal)
if (abortSignal) {
updateReq$ = updateReq$.pipe(takeUntilAborted(abortSignal));
}
const updateRes = await firstValueFrom(updateReq$);
if (updateRes.error) {
this.#logger.error('Failed to update item availability', {
itemId: result.itemId,
itemId: item.id,
});
}
}
if (!result.isAvailable) {
unavailableItemIds.push(result.itemId);
} else {
// No availability data returned = item failed validation (unavailable)
unavailableItemIds.push(item.id);
}
}
@@ -490,18 +516,9 @@ export class CheckoutService {
// Filter shipping items
const shippingItems = filterShippingItems(items);
if (shippingItems.length === 0) return;
// Get branch and logistician in parallel
const [inventoryBranch, omsLogistician] = await Promise.all([
this.#branchService.getDefaultBranch(abortSignal),
this.#logisticianService.getLogistician2470(abortSignal),
]);
// Convert to catalogue format using adapters
const catalogueBranch = BranchAdapter.toCatalogueFormat(inventoryBranch);
const catalogueLogistician =
LogisticianAdapter.toCatalogueFormat(omsLogistician);
if (shippingItems.length === 0) {
return;
}
// Update each shipping item
await Promise.all(
@@ -509,36 +526,59 @@ export class CheckoutService {
const orderType = item.data.features?.['orderType'];
const originalPrice = item.data.availability?.price?.value?.value;
if (!item.data?.product?.ean || !item.id) {
this.#logger.warn('Skipping item with missing EAN or ID');
return;
}
try {
let catalogueAvailability: CatalogueAvailabilityResponse | null =
null;
// Transform item to availability service format
const availabilityItem = {
itemId: item.id,
ean: item.data.product.ean,
price: item.data.availability?.price,
quantity: item.data.quantity ?? 1,
};
// Convert checkout item to catalogue format using adapter
const catalogueItem = ShoppingCartItemAdapter.toCatalogueFormat(item);
let availability: AvailabilityModel | undefined;
// Call new availability service based on order type
if (orderType === 'DIG-Versand') {
catalogueAvailability =
(await this.#availabilityService.getDigDeliveryAvailability(
catalogueItem,
abortSignal,
)) as CatalogueAvailabilityResponse | null;
availability = await this.#availabilityService.getAvailability(
{
orderType: OrderType.DigitalShipping,
item: availabilityItem,
},
abortSignal,
);
} else if (orderType === 'B2B-Versand') {
catalogueAvailability =
(await this.#availabilityService.getB2bDeliveryAvailability(
catalogueItem,
catalogueBranch,
catalogueLogistician,
abortSignal,
)) as CatalogueAvailabilityResponse | null;
availability = await this.#availabilityService.getAvailability(
{
orderType: OrderType.B2BShipping,
item: availabilityItem,
},
abortSignal,
);
} else {
return;
}
if (!catalogueAvailability) return;
if (!availability) {
return;
}
// Convert catalogue availability to checkout format, preserving original price
const availabilityDTO = AvailabilityAdapter.toCheckoutFormat(
catalogueAvailability,
originalPrice,
);
// Convert availability-api format to checkout format, preserving original price
const availabilityDTO =
AvailabilityAdapter.fromAvailabilityApi(availability);
// Preserve original price if it exists
if (originalPrice !== undefined && availabilityDTO.price) {
availabilityDTO.price.value = {
value: originalPrice,
currency: availabilityDTO.price.value?.currency ?? 'EUR',
currencySymbol: availabilityDTO.price.value?.currencySymbol ?? '€',
};
}
let req$ =
this.#shoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem(
@@ -549,7 +589,10 @@ export class CheckoutService {
},
);
if (abortSignal) req$ = req$.pipe(takeUntilAborted(abortSignal));
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {

View File

@@ -17,7 +17,11 @@ import {
UpdateShoppingCartItemParamsSchema,
} from '../schemas';
import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import {
ResponseArgsError,
takeUntilAborted,
ensureCurrencyDefaults,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { CheckoutMetadataService } from './checkout-metadata.service';
@@ -97,6 +101,7 @@ export class ShoppingCartService {
async addItem(params: AddItemToShoppingCartParams): Promise<ShoppingCart> {
const parsed = AddItemToShoppingCartParamsSchema.parse(params);
const req$ =
this.#storeCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart(
{
@@ -276,9 +281,10 @@ export class ShoppingCartService {
},
availability: {
...rewardSelectionItem.item.availability,
price:
price: ensureCurrencyDefaults(
rewardSelectionItem?.availabilityPrice ??
rewardSelectionItem?.catalogPrice,
rewardSelectionItem?.catalogPrice,
),
},
promotion: rewardSelectionItem?.item?.promotion,
quantity: desiredCartQuantity,
@@ -358,7 +364,6 @@ export class ShoppingCartService {
currency: 'EUR',
currencySymbol: '€',
},
vat: undefined,
},
},
},

View File

@@ -59,7 +59,7 @@ export class RewardShoppingCartItemQuantityControlComponent {
item = input.required<ShoppingCartItem>();
quantity = linkedSignal(() => this.item()?.quantity ?? 0);
quantity = linkedSignal(() => this.item()?.quantity ?? 1);
maxQuantity = computed(() => {
const orderType = this.orderType();

View File

@@ -1,3 +1,4 @@
export * from './create-esc-abort-controller.helper';
export * from './is-response-args.helper';
export * from './price-helpers';
export * from './zod-error.helper';

View File

@@ -0,0 +1,37 @@
import { Price } from '../models';
/**
* Ensures that a price object has currency and currencySymbol set.
* If the price has a value but currency or currencySymbol are missing,
* defaults to 'EUR' and '€' respectively.
*
* This is necessary because the backend API requires these fields to be non-null
* when a price value is present, but various adapters and API responses may
* omit these fields.
*
* @param price - The price object to normalize
* @returns The price object with currency defaults applied, or undefined if input was undefined
*
* @example
* const price = {
* value: { value: 10.99 } // Missing currency and currencySymbol
* };
* const normalized = ensureCurrencyDefaults(price);
* // Result: { value: { value: 10.99, currency: 'EUR', currencySymbol: '€' } }
*/
export function ensureCurrencyDefaults(
price: Price | undefined,
): Price | undefined {
if (!price?.value) {
return price;
}
return {
...price,
value: {
...price.value,
currency: price.value.currency ?? 'EUR',
currencySymbol: price.value.currencySymbol ?? '€',
},
};
}

View File

@@ -5,6 +5,7 @@ export * from './entity-cotnainer';
export * from './entity-status';
export * from './gender';
export * from './list-response-args';
export * from './order-type';
export * from './payer-type';
export * from './price-value';
export * from './price';

View File

@@ -0,0 +1,10 @@
export const OrderType = {
InStore: 'Rücklage',
Pickup: 'Abholung',
Delivery: 'Versand',
DigitalShipping: 'DIG-Versand',
B2BShipping: 'B2B-Versand',
Download: 'Download',
} as const;
export type OrderType = (typeof OrderType)[keyof typeof OrderType];

View File

@@ -2,6 +2,10 @@ import { z } from 'zod';
export const PriceValueSchema = z.object({
value: z.number().describe('Value').optional(),
currency: z.string().describe('Currency code').optional(),
currencySymbol: z.string().describe('Currency symbol').optional(),
currency: z.string().describe('Currency code').default('EUR').optional(),
currencySymbol: z
.string()
.describe('Currency symbol')
.default('€')
.optional(),
});

View File

@@ -4,6 +4,6 @@ import { VatTypeSchema } from './vat-type.schema';
export const VatValueSchema = z.object({
value: z.number().describe('Value').optional(),
label: z.string().describe('Label').optional(),
inPercent: z.number().describe('In percent').optional(),
vatType: VatTypeSchema.describe('VAT type').optional(),
inPercent: z.number().describe('In percent').default(0).optional(),
vatType: VatTypeSchema.describe('VAT type').default(0).optional(),
});