mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 14:32:10 +01:00
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:
committed by
Nino Righi
parent
2d654aa63a
commit
bfd151dd84
@@ -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;
|
||||
|
||||
334
graph.json
334
graph.json
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { OrderType } from '@isa/checkout/data-access';
|
||||
export { OrderType } from '@isa/common/data-access';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from './availability.service';
|
||||
export * from './catalouge-search.service';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
37
libs/common/data-access/src/lib/helpers/price-helpers.ts
Normal file
37
libs/common/data-access/src/lib/helpers/price-helpers.ts
Normal 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 ?? '€',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
10
libs/common/data-access/src/lib/models/order-type.ts
Normal file
10
libs/common/data-access/src/lib/models/order-type.ts
Normal 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];
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user