Merged PR 2009: fix shopping cart - sync issues

This commit is contained in:
Lorenz Hilpert
2025-11-07 12:18:31 +00:00
committed by Nino Righi
parent f04e36e710
commit 731df8414d
10 changed files with 1101 additions and 235 deletions

View File

@@ -72,12 +72,82 @@ import { ApplicationService } from '@core/application';
import { CustomerDTO } from '@generated/swagger/crm-api';
import { Config } from '@core/config';
import parseDuration from 'parse-duration';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
import {
CheckoutMetadataService,
ShoppingCart,
} from '@isa/checkout/data-access';
import {
ShoppingCartEvent,
ShoppingCartEvents,
} from '@isa/checkout/data-access';
/**
* Domain service for managing the complete checkout flow including shopping cart operations,
* checkout creation, buyer/payer management, payment processing, and order completion.
*
* This service orchestrates interactions between:
* - NgRx Store for state management
* - Multiple Swagger API clients (checkout, OMS, shopping cart, payment, buyer, payer, branch, kulturpass)
* - Shopping cart event system for cross-component synchronization
* - Availability service for real-time product availability checks
*
* @remarks
* **Process ID Pattern**: All methods require a `processId` (typically `Date.now()`) to isolate
* checkout sessions. Multiple concurrent checkout processes can run independently.
*
* **Observable-First Design**: Most methods return Observables for reactive composition. Consumers
* should use RxJS operators for transformation and error handling.
*
* **Auto-Creation**: Shopping carts auto-create if missing. The service uses `filter()` operators
* to trigger lazy initialization and prevent race conditions.
*
* **Event Sourcing**: Publishes shopping cart events (Created, ItemAdded, ItemUpdated, ItemRemoved)
* for synchronization across components. Subscribes to events from `ShoppingCartService` to maintain
* consistency.
*
* **OLA Management**: Tracks Order Level Agreement (OLA) expiration timestamps per item and order type.
* Validates availability freshness before checkout completion.
*
* @example
* ```typescript
* // Basic shopping cart flow
* const processId = Date.now();
*
* // Add items to cart
* this.checkoutService.addItemToShoppingCart({
* processId,
* items: [{ productId: 123, quantity: 2 }]
* }).subscribe();
*
* // Get cart (auto-creates if missing)
* this.checkoutService.getShoppingCart({ processId })
* .subscribe(cart => console.log(cart));
*
* // Complete checkout (orchestrates all steps)
* this.checkoutService.completeCheckout({ processId })
* .subscribe(orders => console.log('Orders created:', orders));
* ```
*
* @see DomainCheckoutSelectors For state selection patterns
* @see DomainCheckoutActions For available actions
* @see ShoppingCartEvents For event system integration
*/
@Injectable()
export class DomainCheckoutService {
/** Metadata service for shopping cart persistence */
#checkoutMetadataService = inject(CheckoutMetadataService);
/** Event bus for shopping cart synchronization across components */
#shoppingCartEvents = inject(ShoppingCartEvents);
/**
* Gets the OLA (Order Level Agreement) expiration duration in milliseconds.
*
* OLA expiration defines how long availability data remains valid before requiring refresh.
* Default is 5 minutes if not configured.
*
* @returns Duration in milliseconds
*/
get olaExpiration() {
const exp = this._config.get('@domain/checkout.olaExpiration') ?? '5m';
return parseDuration(exp);
@@ -96,9 +166,56 @@ export class DomainCheckoutService {
private _payerService: StoreCheckoutPayerService,
private _branchService: StoreCheckoutBranchService,
private _kulturpassService: KulturPassService,
) {}
) {
// Subscribe to shopping cart events from ShoppingCartService
this.#shoppingCartEvents.events$
.pipe(
// Only process events from ShoppingCartService to avoid circular updates
filter((payload) => payload.source === 'ShoppingCartService'),
)
.subscribe((payload) => {
// Update the store with the shopping cart from the event
this.store.dispatch(
DomainCheckoutActions.setShoppingCartByShoppingCartId({
shoppingCartId: payload.shoppingCart.id!,
shoppingCart: payload.shoppingCart as any,
}),
);
});
}
//#region shoppingcart
/**
* Retrieves the shopping cart for a given process ID. Auto-creates the cart if it doesn't exist.
*
* @remarks
* **Auto-Creation**: If no cart exists for the process ID, triggers `createShoppingCart()` automatically.
* The Observable filters out null/undefined and waits for cart creation to complete.
*
* **Latest Data**: Setting `latest: true` forces a refresh from the API instead of using cached state.
* This is useful before critical operations like checkout completion.
*
* **Memoization**: Uses `@memorize()` decorator to cache results by parameters, reducing duplicate calls.
*
* @param params - Parameters object
* @param params.processId - Unique process identifier (typically `Date.now()`)
* @param params.latest - If true, fetches fresh data from API; if false/undefined, uses store state
*
* @returns Observable of the shopping cart DTO. Never emits null/undefined.
*
* @example
* ```typescript
* // Get cart from store (creates if missing)
* this.checkoutService.getShoppingCart({ processId: 123 })
* .pipe(first())
* .subscribe(cart => console.log('Items:', cart.items));
*
* // Force refresh from API
* this.checkoutService.getShoppingCart({ processId: 123, latest: true })
* .pipe(first())
* .subscribe(cart => console.log('Fresh cart:', cart));
* ```
*/
@memorize()
getShoppingCart({
processId,
@@ -143,6 +260,24 @@ export class DomainCheckoutService {
);
}
/**
* Reloads the shopping cart from the API and updates the store with fresh data.
*
* This is an async method that fetches the latest cart state from the backend
* and dispatches an action to update the NgRx store. Unlike `getShoppingCart({ latest: true })`,
* this method doesn't return the cart Observable - it's fire-and-forget.
*
* @param params - Parameters object
* @param params.processId - Process identifier
*
* @returns Promise that resolves when reload completes (or immediately if no cart exists)
*
* @example
* ```typescript
* await this.checkoutService.reloadShoppingCart({ processId: 123 });
* console.log('Cart reloaded');
* ```
*/
async reloadShoppingCart({ processId }: { processId: number }) {
const shoppingCart = await firstValueFrom(
this.store.select(DomainCheckoutSelectors.selectShoppingCartByProcessId, {
@@ -166,6 +301,29 @@ export class DomainCheckoutService {
);
}
/**
* Creates a new shopping cart and associates it with the given process ID.
*
* @remarks
* **State Updates**:
* - Saves shopping cart ID to metadata service for persistence
* - Publishes `ShoppingCartEvent.Created` event for component synchronization
* - Dispatches `setShoppingCart` action to update NgRx store
*
* **Auto-Invocation**: Usually called automatically by `getShoppingCart()` when no cart exists.
* Rarely needs to be called directly.
*
* @param params - Parameters object
* @param params.processId - Process identifier to associate with the new cart
*
* @returns Observable of the newly created shopping cart DTO
*
* @example
* ```typescript
* this.checkoutService.createShoppingCart({ processId: Date.now() })
* .subscribe(cart => console.log('Cart created:', cart.id));
* ```
*/
createShoppingCart({
processId,
}: {
@@ -180,6 +338,11 @@ export class DomainCheckoutService {
processId,
shoppingCart.id,
);
this.#shoppingCartEvents.pub(
ShoppingCartEvent.Created,
shoppingCart as ShoppingCart,
'DomainCheckoutService',
);
this.store.dispatch(
DomainCheckoutActions.setShoppingCart({
processId,
@@ -190,6 +353,41 @@ export class DomainCheckoutService {
);
}
/**
* Adds one or more items to the shopping cart.
*
* @remarks
* **Process Flow**:
* 1. Retrieves existing shopping cart (creates if missing)
* 2. Calls API to add items
* 3. Publishes `ShoppingCartEvent.ItemAdded` event
* 4. Updates NgRx store with modified cart
*
* **Validation**: Ensure items are validated via `canAddItem()` or `canAddItems()` before calling
* this method to avoid API errors.
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.items - Array of items to add (product, quantity, availability, destination, etc.)
*
* @returns Observable of the updated shopping cart with new items
*
* @example
* ```typescript
* this.checkoutService.addItemToShoppingCart({
* processId: 123,
* items: [{
* product: { ean: '1234567890', catalogProductNumber: 456 },
* quantity: 2,
* availability: availabilityDto,
* destination: destinationDto
* }]
* }).subscribe(cart => console.log('Items:', cart.items.length));
* ```
*
* @see canAddItem For single item validation
* @see canAddItems For bulk item validation
*/
addItemToShoppingCart({
processId,
items,
@@ -208,6 +406,11 @@ export class DomainCheckoutService {
.pipe(
map((response) => response.result),
tap((shoppingCart) => {
this.#shoppingCartEvents.pub(
ShoppingCartEvent.ItemAdded,
shoppingCart as ShoppingCart,
'DomainCheckoutService',
);
this.store.dispatch(
DomainCheckoutActions.setShoppingCart({
processId,
@@ -267,6 +470,37 @@ export class DomainCheckoutService {
);
}
/**
* Validates if a single item can be added to the shopping cart.
*
* Checks business rules, customer features, and cart compatibility before adding items.
* Use this before calling `addItemToShoppingCart()` to prevent API errors.
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.availability - OLA availability data for the item
* @param params.orderType - Order type (e.g., 'Abholung', 'Versand', 'Download', 'Rücklage')
*
* @returns Observable of `true` if item can be added, or error message string if not allowed
*
* @example
* ```typescript
* this.checkoutService.canAddItem({
* processId: 123,
* availability: olaAvailability,
* orderType: 'Versand'
* }).subscribe(result => {
* if (result === true) {
* // Proceed with adding item
* this.checkoutService.addItemToShoppingCart(...);
* } else {
* console.error('Cannot add item:', result);
* }
* });
* ```
*
* @see canAddItems For bulk validation
*/
canAddItem({
processId,
availability,
@@ -312,6 +546,38 @@ export class DomainCheckoutService {
.pipe(map((response) => response?.result));
}
/**
* Validates if multiple items can be added to the shopping cart in bulk.
*
* More efficient than calling `canAddItem()` multiple times. Returns validation
* results for each item in the payload.
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.payload - Array of item payloads to validate
* @param params.orderType - Order type for all items
*
* @returns Observable array of validation results (one per item). Each result contains
* `ok` flag and optional error messages.
*
* @example
* ```typescript
* this.checkoutService.canAddItems({
* processId: 123,
* payload: [
* { availabilities: [avail1], productId: 111, quantity: 2 },
* { availabilities: [avail2], productId: 222, quantity: 1 }
* ],
* orderType: 'Versand'
* }).subscribe(results => {
* results.forEach((result, index) => {
* console.log(`Item ${index}:`, result.ok ? 'Valid' : result.message);
* });
* });
* ```
*
* @see canAddItem For single item validation
*/
canAddItems({
processId,
payload,
@@ -383,6 +649,50 @@ export class DomainCheckoutService {
);
}
/**
* Updates an existing item in the shopping cart (quantity, availability, special comment, etc.).
*
* @remarks
* **Special Behavior**:
* - Setting `quantity: 0` removes the item and publishes `ItemRemoved` event instead of `ItemUpdated`
* - Always fetches latest cart state (`latest: true`) to avoid stale data conflicts
* - If availability is updated, adds timestamp to history for OLA validation
*
* **Event Publishing**:
* - Publishes `ShoppingCartEvent.ItemRemoved` if quantity is 0
* - Publishes `ShoppingCartEvent.ItemUpdated` for all other changes
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.shoppingCartItemId - ID of the cart item to update
* @param params.update - Fields to update (quantity, availability, specialComment, etc.)
*
* @returns Observable of the updated shopping cart
*
* @example
* ```typescript
* // Update quantity
* this.checkoutService.updateItemInShoppingCart({
* processId: 123,
* shoppingCartItemId: 456,
* update: { quantity: 5 }
* }).subscribe();
*
* // Remove item (quantity = 0)
* this.checkoutService.updateItemInShoppingCart({
* processId: 123,
* shoppingCartItemId: 456,
* update: { quantity: 0 }
* }).subscribe();
*
* // Update availability
* this.checkoutService.updateItemInShoppingCart({
* processId: 123,
* shoppingCartItemId: 456,
* update: { availability: newAvailabilityDto }
* }).subscribe();
* ```
*/
updateItemInShoppingCart({
processId,
shoppingCartItemId,
@@ -404,6 +714,17 @@ export class DomainCheckoutService {
.pipe(
map((response) => response.result),
tap((shoppingCart) => {
// Check if item was removed (quantity === 0)
const eventType =
update.quantity === 0
? ShoppingCartEvent.ItemRemoved
: ShoppingCartEvent.ItemUpdated;
this.#shoppingCartEvents.pub(
eventType,
shoppingCart as ShoppingCart,
'DomainCheckoutService',
);
this.store.dispatch(
DomainCheckoutActions.setShoppingCart({
processId,
@@ -432,6 +753,34 @@ export class DomainCheckoutService {
//#region Checkout
/**
* Retrieves the checkout entity for a given process. Auto-creates if missing.
*
* @remarks
* **Auto-Creation**: Similar to `getShoppingCart()`, automatically triggers `createCheckout()`
* if no checkout exists for the process ID.
*
* **Refresh**: Setting `refresh: true` forces recreation of the checkout entity from the API.
*
* **Purpose**: The checkout entity aggregates buyer, payer, payment, destinations, and
* notification channels. It's required before order completion.
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.refresh - If true, recreates checkout from API; if false/undefined, uses store state
*
* @returns Observable of the checkout DTO. Never emits null/undefined.
*
* @example
* ```typescript
* this.checkoutService.getCheckout({ processId: 123 })
* .pipe(first())
* .subscribe(checkout => {
* console.log('Buyer:', checkout.buyer);
* console.log('Payment:', checkout.payment);
* });
* ```
*/
getCheckout({
processId,
refresh,
@@ -772,6 +1121,40 @@ export class DomainCheckoutService {
);
}
/**
* Refreshes the availability data for a single shopping cart item.
*
* Fetches fresh availability from the appropriate service based on order type
* (Abholung, Rücklage, Download, Versand, DIG-Versand, B2B-Versand) and updates
* the cart item.
*
* @remarks
* **Order Type Handling**:
* - **Abholung** (Pickup): Requires branch for availability check
* - **Rücklage** (TakeAway): Requires branch for availability check
* - **Download**: No additional parameters needed
* - **Versand** (Delivery): Standard delivery availability
* - **DIG-Versand** (Digital Delivery): Digital goods delivery
* - **B2B-Versand** (B2B Delivery): Business customer delivery
*
* **Updates**: Automatically calls `updateItemInShoppingCart()` with the new availability
* after fetching.
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.shoppingCartItemId - ID of the cart item to refresh
*
* @returns Promise of the refreshed availability DTO, or undefined if item not found
*
* @example
* ```typescript
* const availability = await this.checkoutService.refreshAvailability({
* processId: 123,
* shoppingCartItemId: 456
* });
* console.log('Updated availability:', availability);
* ```
*/
async refreshAvailability({
processId,
shoppingCartItemId,
@@ -876,9 +1259,41 @@ export class DomainCheckoutService {
}
/**
* Check if the availability of all items is valid
* @param param0 Process Id
* @returns true if the availability of all items is valid
* Validates if all shopping cart items have fresh availability data (OLA not expired).
*
* OLA (Order Level Agreement) defines how long availability data remains valid.
* This method polls the store at regular intervals and checks if the oldest
* availability timestamp is still within the expiration window.
*
* @remarks
* **Polling**: Uses `rxjsInterval()` to continuously check OLA status. The Observable emits
* `true` while all items are valid, `false` when any item expires.
*
* **Timestamp Tracking**: Tracks timestamps per `${itemId}_${orderType}` combination.
* Shipping types (Versand, DIG-Versand, B2B-Versand) fall back to generic 'Versand' timestamp.
*
* **Default Interval**: Polls every `olaExpiration / 10` milliseconds (default: 30 seconds for 5-minute expiration).
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.interval - Custom polling interval in milliseconds (optional)
*
* @returns Observable that emits `true` when OLA is valid, `false` when expired.
* Emits only on changes (`distinctUntilChanged()`).
*
* @example
* ```typescript
* this.checkoutService.validateOlaStatus({ processId: 123 })
* .subscribe(isValid => {
* if (!isValid) {
* console.warn('Availability data expired! Refresh required.');
* // Trigger availability refresh
* }
* });
* ```
*
* @see olaExpiration For expiration duration configuration
* @see checkoutIsValid For combined OLA + availability validation
*/
validateOlaStatus({
processId,
@@ -965,6 +1380,39 @@ export class DomainCheckoutService {
);
}
/**
* Validates if the checkout is ready for order completion.
*
* Combines OLA status validation with availability validation. Both must be true
* for checkout to proceed.
*
* @remarks
* **Validation Checks**:
* - OLA Status: All item availabilities are within expiration window
* - Availabilities: All items are marked as available (not out of stock)
*
* **Polling**: Uses fast polling (250ms) for OLA status to catch expiration quickly.
*
* @param params - Parameters object
* @param params.processId - Process identifier
*
* @returns Observable that emits `true` when checkout is valid, `false` otherwise.
* Emits on every change in either validation.
*
* @example
* ```typescript
* this.checkoutService.checkoutIsValid({ processId: 123 })
* .subscribe(isValid => {
* this.checkoutButton.disabled = !isValid;
* if (!isValid) {
* this.showWarning('Checkout unavailable: Availability expired');
* }
* });
* ```
*
* @see validateOlaStatus For OLA-only validation
* @see validateAvailabilities For availability-only validation
*/
checkoutIsValid({ processId }: { processId: number }): Observable<boolean> {
const olaStatus$ = this.validateOlaStatus({ processId, interval: 250 });
@@ -975,6 +1423,73 @@ export class DomainCheckoutService {
);
}
/**
* Orchestrates the complete checkout process from cart validation to order creation.
*
* This is the most complex method in the service, executing a 12-step sequence that
* validates data, updates entities, and creates orders. Each step must complete before
* the next begins.
*
* @remarks
* **Execution Sequence** (sequential, not parallel):
* 1. **Update Destination**: Sets shipping addresses on delivery destinations
* 2. **Refresh Shopping Cart**: Gets latest cart state from API
* 3. **Set Special Comments**: Applies agent comments to all cart items
* 4. **Refresh Checkout**: Recreates checkout entity from current state
* 5. **Check Availabilities**: Validates download items are available
* 6. **Update Availabilities**: Refreshes DIG-Versand and B2B-Versand prices
* 7. **Set Buyer**: Submits buyer information to checkout
* 8. **Set Notification Channels**: Configures email/SMS preferences
* 9. **Set Payer**: Submits payer information (if needed for order type)
* 10. **Set Payment Type**: Configures payment method (Rechnung/Bar)
* 11. **Set Destination**: Updates destinations with shipping addresses
* 12. **Complete Order**: Submits to OMS for order creation
*
* **Payment Type Logic**:
* - Download/Versand/DIG-Versand/B2B-Versand → Payment type 128 (Rechnung/Invoice)
* - Pickup/TakeAway only → Payment type 4 (Bar/Cash)
*
* **Payer Requirement**:
* - Required for B2B customers or Download/Delivery order types
* - Skipped for in-store pickup/takeaway only
*
* **Error Handling**:
* - HTTP 409 (Conflict): Order already exists - dispatches existing orders to store
* - Other errors propagate to consumer for handling
* - Failed availability checks throw error preventing order creation
*
* **Side Effects**:
* - Logs each step to console (for debugging)
* - Updates NgRx store at multiple points
* - Dispatches final orders to store on success
*
* @param params - Parameters object
* @param params.processId - Process identifier
*
* @returns Observable array of created orders (DisplayOrderDTO[])
*
* @throws Observable error if availability validation fails or API returns non-409 error
*
* @example
* ```typescript
* this.checkoutService.completeCheckout({ processId: 123 })
* .subscribe({
* next: (orders) => {
* console.log('Orders created:', orders);
* this.router.navigate(['/order-confirmation']);
* },
* error: (error) => {
* if (error.status === 409) {
* console.log('Order already exists');
* } else {
* console.error('Checkout failed:', error);
* }
* }
* });
* ```
*
* @see completeKulturpassOrder For Kulturpass-specific checkout flow
*/
completeCheckout({
processId,
}: {
@@ -1328,11 +1843,45 @@ export class DomainCheckoutService {
//#region Common
// Fix für Ticket #4619 Versand Artikel im Warenkob -> keine Änderung bei Kundendaten erfassen
// Auskommentiert, da dieser Aufruf oftmals mit gleichen Parametern aufgerufen wird (ohne ausgewählten Kunden nur ein leeres Objekt bei customerFeatures)
// memorize macht keinen deepCompare von Objekten und denkt hier, dass immer der gleiche Return Wert zurückkommt, allerdings ist das hier oft nicht der Fall
// und der Decorator memorized dann fälschlicherweise
// @memorize()
/**
* Validates if a customer can be set on the shopping cart based on cart contents and customer features.
*
* @remarks
* **Memoization Disabled**: The `@memorize()` decorator was intentionally disabled for this method
* due to shallow comparison issues. The decorator couldn't detect when `customerFeatures` object
* changed, causing stale cached results. See Ticket #4619.
*
* **Response Fields**:
* - `ok`: True if customer can be set without issues
* - `filter`: Customer search filters (e.g., `{ customertype: 'webshop;guest' }`)
* - `message`: Error message if validation fails
* - `create`: Options for creating new customer types (store, guest, webshop, b2b)
*
* **Use Cases**:
* - Determine which customer types are compatible with cart contents
* - Pre-filter customer search results
* - Enable/disable customer type creation buttons
*
* @param params - Parameters object
* @param params.processId - Process identifier
* @param params.customerFeatures - Customer feature flags (optional: webshop, guest, b2b, staff, etc.)
*
* @returns Observable with validation result containing ok, filter, message, and create options
*
* @example
* ```typescript
* this.checkoutService.canSetCustomer({
* processId: 123,
* customerFeatures: { webshop: 'true', guest: 'false' }
* }).subscribe(result => {
* if (result.ok) {
* console.log('Customer types allowed:', result.create.options.values);
* } else {
* console.error('Cannot set customer:', result.message);
* }
* });
* ```
*/
canSetCustomer({
processId,
customerFeatures,
@@ -1467,6 +2016,32 @@ export class DomainCheckoutService {
);
}
/**
* Retrieves all active, online branches that support shipping.
*
* @remarks
* **Filtering**: Returns only branches matching ALL criteria:
* - `status === 1` (Active)
* - `branchType === 1` (Standard branch type)
* - `isOnline === true` (Available online)
* - `isShippingEnabled === true` (Supports shipping)
*
* **Memoization**: Uses `@memorize()` decorator to cache results. Subsequent calls
* return cached data without API round-trip.
*
* **Pagination**: Fetches up to 999 branches (effectively all branches).
*
* @returns Observable array of filtered branch DTOs
*
* @example
* ```typescript
* this.checkoutService.getBranches()
* .subscribe(branches => {
* console.log('Available branches:', branches.length);
* this.branchDropdown.options = branches;
* });
* ```
*/
@memorize()
getBranches(): Observable<BranchDTO[]> {
return this._branchService
@@ -1547,6 +2122,21 @@ export class DomainCheckoutService {
);
}
/**
* Removes all checkout data for a process ID from the store.
*
* Cleans up shopping cart, checkout entity, buyer, payer, and all associated data.
* Call this when a checkout session is complete or cancelled.
*
* @param params - Parameters object
* @param params.processId - Process identifier to remove
*
* @example
* ```typescript
* // After successful order or when user cancels
* this.checkoutService.removeProcess({ processId: 123 });
* ```
*/
removeProcess({ processId }: { processId: number }) {
this.store.dispatch(
DomainCheckoutActions.removeCheckoutWithProcessId({ processId }),

View File

@@ -19,6 +19,11 @@ export const setShoppingCart = createAction(
props<{ processId: number; shoppingCart: ShoppingCartDTO }>(),
);
export const setShoppingCartByShoppingCartId = createAction(
`${prefix} Set Shopping Cart By Shopping Cart Id`,
props<{ shoppingCartId: number; shoppingCart: ShoppingCartDTO }>(),
);
export const setCheckout = createAction(
`${prefix} Set Checkout`,
props<{ processId: number; checkout: CheckoutDTO }>(),

View File

@@ -1,207 +1,311 @@
import { createReducer, on } from '@ngrx/store';
import { initialCheckoutState, storeCheckoutAdapter } from './domain-checkout.state';
import * as DomainCheckoutActions from './domain-checkout.actions';
import { Dictionary } from '@ngrx/entity';
import { CheckoutEntity } from './defs/checkout.entity';
import { isNullOrUndefined } from '@utils/common';
const _domainCheckoutReducer = createReducer(
initialCheckoutState,
on(DomainCheckoutActions.setShoppingCart, (s, { processId, shoppingCart }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const addedShoppingCartItems =
shoppingCart?.items
?.filter((item) => !entity.shoppingCart?.items?.find((i) => i.id === item.id))
?.map((item) => item.data) ?? [];
entity.shoppingCart = shoppingCart;
entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp ? { ...entity.itemAvailabilityTimestamp } : {};
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`] = now;
}
}
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.checkout = checkout;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setBuyerCommunicationDetails, (s, { processId, email, mobile }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const communicationDetails = { ...entity.buyer.communicationDetails };
communicationDetails.email = email || communicationDetails.email;
communicationDetails.mobile = mobile || communicationDetails.mobile;
entity.buyer = {
...entity.buyer,
communicationDetails,
};
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setNotificationChannels, (s, { processId, notificationChannels }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
return storeCheckoutAdapter.setOne({ ...entity, notificationChannels }, s);
}),
on(DomainCheckoutActions.setCheckoutDestination, (s, { processId, destination }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.checkout = {
...entity.checkout,
destinations: entity.checkout.destinations.map((dest) => {
if (dest.id === destination.id) {
return { ...dest, ...destination };
}
return { ...dest };
}),
};
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setShippingAddress, (s, { processId, shippingAddress }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.shippingAddress = shippingAddress;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.buyer = buyer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.payer = payer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setSpecialComment, (s, { processId, agentComment }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.specialComment = agentComment;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => {
return storeCheckoutAdapter.removeOne(processId, s);
}),
on(DomainCheckoutActions.setOrders, (s, { orders }) => ({ ...s, orders: [...s.orders, ...orders] })),
on(DomainCheckoutActions.updateOrderItem, (s, { item }) => {
const orders = [...s.orders];
const orderToUpdate = orders?.find((order) => order.items?.find((i) => i.id === item?.id));
const orderToUpdateIndex = orders?.indexOf(orderToUpdate);
const orderItemToUpdate = orderToUpdate?.items?.find((i) => i.id === item?.id);
const orderItemToUpdateIndex = orderToUpdate?.items?.indexOf(orderItemToUpdate);
const items = [...orderToUpdate?.items];
items[orderItemToUpdateIndex] = item;
orders[orderToUpdateIndex] = {
...orderToUpdate,
items: [...items],
};
return { ...s, orders: [...orders] };
}),
on(DomainCheckoutActions.removeAllOrders, (s) => ({
...s,
orders: [],
})),
on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.olaErrorIds = olaErrorIds;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.customer = customer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory,
(s, { processId, shoppingCartItemId, availability }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId,
(s, { shoppingCartId, shoppingCartItemId, availability }) => {
const entity = getCheckoutEntityByShoppingCartId({ shoppingCartId, entities: s.entities });
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
);
export function domainCheckoutReducer(state, action) {
return _domainCheckoutReducer(state, action);
}
function getOrCreateCheckoutEntity({
entities,
processId,
}: {
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {
processId,
checkout: undefined,
shoppingCart: undefined,
shippingAddress: undefined,
orders: [],
payer: undefined,
buyer: undefined,
specialComment: '',
notificationChannels: 0,
olaErrorIds: [],
customer: undefined,
// availabilityHistory: [],
itemAvailabilityTimestamp: {},
};
}
return { ...entity };
}
function getCheckoutEntityByShoppingCartId({
entities,
shoppingCartId,
}: {
entities: Dictionary<CheckoutEntity>;
shoppingCartId: number;
}): CheckoutEntity {
return Object.values(entities).find((entity) => entity.shoppingCart?.id === shoppingCartId);
}
import { createReducer, on } from '@ngrx/store';
import {
initialCheckoutState,
storeCheckoutAdapter,
} from './domain-checkout.state';
import * as DomainCheckoutActions from './domain-checkout.actions';
import { Dictionary } from '@ngrx/entity';
import { CheckoutEntity } from './defs/checkout.entity';
import { isNullOrUndefined } from '@utils/common';
const _domainCheckoutReducer = createReducer(
initialCheckoutState,
on(
DomainCheckoutActions.setShoppingCart,
(s, { processId, shoppingCart }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const addedShoppingCartItems =
shoppingCart?.items
?.filter(
(item) =>
!entity.shoppingCart?.items?.find((i) => i.id === item.id),
)
?.map((item) => item.data) ?? [];
entity.shoppingCart = shoppingCart;
entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp
? { ...entity.itemAvailabilityTimestamp }
: {};
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[
`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`
] = now;
}
}
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setShoppingCartByShoppingCartId,
(s, { shoppingCartId, shoppingCart }) => {
let entity = getCheckoutEntityByShoppingCartId({
shoppingCartId,
entities: s.entities,
});
if (!entity) {
// No entity found for this shoppingCartId, cannot update
return s;
}
entity = { ...entity, shoppingCart };
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.checkout = checkout;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.setBuyerCommunicationDetails,
(s, { processId, email, mobile }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const communicationDetails = { ...entity.buyer.communicationDetails };
communicationDetails.email = email || communicationDetails.email;
communicationDetails.mobile = mobile || communicationDetails.mobile;
entity.buyer = {
...entity.buyer,
communicationDetails,
};
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setNotificationChannels,
(s, { processId, notificationChannels }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
return storeCheckoutAdapter.setOne(
{ ...entity, notificationChannels },
s,
);
},
),
on(
DomainCheckoutActions.setCheckoutDestination,
(s, { processId, destination }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.checkout = {
...entity.checkout,
destinations: entity.checkout.destinations.map((dest) => {
if (dest.id === destination.id) {
return { ...dest, ...destination };
}
return { ...dest };
}),
};
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setShippingAddress,
(s, { processId, shippingAddress }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.shippingAddress = shippingAddress;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.buyer = buyer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.payer = payer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.setSpecialComment,
(s, { processId, agentComment }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.specialComment = agentComment;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => {
return storeCheckoutAdapter.removeOne(processId, s);
}),
on(DomainCheckoutActions.setOrders, (s, { orders }) => ({
...s,
orders: [...s.orders, ...orders],
})),
on(DomainCheckoutActions.updateOrderItem, (s, { item }) => {
const orders = [...s.orders];
const orderToUpdate = orders?.find((order) =>
order.items?.find((i) => i.id === item?.id),
);
const orderToUpdateIndex = orders?.indexOf(orderToUpdate);
const orderItemToUpdate = orderToUpdate?.items?.find(
(i) => i.id === item?.id,
);
const orderItemToUpdateIndex =
orderToUpdate?.items?.indexOf(orderItemToUpdate);
const items = [...(orderToUpdate?.items ?? [])];
items[orderItemToUpdateIndex] = item;
orders[orderToUpdateIndex] = {
...orderToUpdate,
items: [...items],
};
return { ...s, orders: [...orders] };
}),
on(DomainCheckoutActions.removeAllOrders, (s) => ({
...s,
orders: [],
})),
on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.olaErrorIds = olaErrorIds;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.customer = customer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory,
(s, { processId, shoppingCartItemId, availability }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find(
(i) => i.id === shoppingCartItemId,
)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] =
Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId,
(s, { shoppingCartId, shoppingCartItemId, availability }) => {
const entity = getCheckoutEntityByShoppingCartId({
shoppingCartId,
entities: s.entities,
});
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find(
(i) => i.id === shoppingCartItemId,
)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] =
Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
);
export function domainCheckoutReducer(state, action) {
return _domainCheckoutReducer(state, action);
}
function getOrCreateCheckoutEntity({
entities,
processId,
}: {
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {
processId,
checkout: undefined,
shoppingCart: undefined,
shippingAddress: undefined,
orders: [],
payer: undefined,
buyer: undefined,
specialComment: '',
notificationChannels: 0,
olaErrorIds: [],
customer: undefined,
// availabilityHistory: [],
itemAvailabilityTimestamp: {},
};
}
return { ...entity };
}
function getCheckoutEntityByShoppingCartId({
entities,
shoppingCartId,
}: {
entities: Dictionary<CheckoutEntity>;
shoppingCartId: number;
}): CheckoutEntity {
return Object.values(entities).find(
(entity) => entity.shoppingCart?.id === shoppingCartId,
);
}

View File

@@ -638,6 +638,8 @@ export class CheckoutReviewComponent
this.#checkoutService.reloadShoppingCart({
processId: this.applicationService.activatedProcessId,
});
this.refreshAvailabilities();
}
async changeAddress() {

View File

@@ -21,7 +21,7 @@
(click)="$event?.preventDefault(); $event?.stopPropagation()"
>
<shared-icon icon="shopping-cart-bold" [size]="22"></shared-icon>
<span class="shopping-cart-count-label ml-2">{{ cartCount() }}</span>
<span class="shopping-cart-count-label ml-2">{{ itemCount() }}</span>
</button>
}
</a>

View File

@@ -11,6 +11,8 @@ import {
inject,
computed,
input,
effect,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
@@ -44,6 +46,7 @@ export class ShellProcessBarItemComponent
{
#tabService = inject(TabService);
#checkoutMetadataService = inject(CheckoutMetadataService);
#shoppingCartResource = inject(ShoppingCartResource);
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
@@ -51,6 +54,18 @@ export class ShellProcessBarItemComponent
return this.#checkoutMetadataService.getShoppingCartId(this.process().id);
});
shoppingCartIdEffect = effect(() => {
const shoppingCartId = this.shoppingCartId();
untracked(() =>
this.#shoppingCartResource.setShoppingCartId(shoppingCartId),
);
});
itemCount = computed(() => {
const shoppingCart = this.#shoppingCartResource.resource.value();
return shoppingCart?.items?.length ?? 0;
});
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
process$ = this._process$.asObservable();
@@ -61,22 +76,7 @@ export class ShellProcessBarItemComponent
closed = new EventEmitter();
showCart = computed(() => {
// TODO: Nach Prämie release kann die Logik wieder rein
return true;
const tab = this.tab();
const pdata = tab.metadata?.process_data as { count?: number };
if (!pdata) {
return false;
}
return 'count' in pdata;
});
cartCount = computed(() => {
// TODO: Use implementation from develop
return 0;
});
currentLocationUrlTree = computed(() => {