Merged PR 2015: fix(crm-data-access, customer-details, reward-shopping-cart): persist selecte...

fix(crm-data-access, customer-details, reward-shopping-cart): persist selected addresses across navigation flows

Implement address selection persistence using CRM tab metadata to ensure
selected shipping addresses and payers are retained throughout the customer
selection flow, particularly when navigating from Kundenkarte to reward cart.

Changes include:
- Create PayerResource and CustomerPayerAddressResource to load selected
  payer from tab metadata with fallback to customer as payer
- Create PayerService to fetch payer data from CRM API with proper error
  handling and abort signal support
- Update BillingAndShippingAddressCardComponent to prefer selected addresses
  from metadata over customer defaults, with computed loading state
- Refactor continue() flow in CustomerDetailsViewMainComponent to load
  selected addresses from metadata before setting in checkout service
- Add adapter logic to convert CRM payer/shipping address types to checkout
  types with proper type casting for incompatible enum types
- Implement fallback chain: metadata selection → component state → customer
  default for both payer and shipping address

This ensures address selections made in the address selection dialogs are
properly preserved and applied when completing the customer selection flow,
fixing the issue where addresses would revert to customer defaults.

Ref: #5411
This commit is contained in:
Nino Righi
2025-11-10 15:10:56 +00:00
committed by Lorenz Hilpert
parent b984a2cac2
commit c3e9a03169
10 changed files with 309 additions and 20 deletions

View File

@@ -0,0 +1,54 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, PayerService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmPayer } from '../schemas';
@Injectable()
export class CustomerPayerAddressResource {
#payerService = inject(PayerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<CrmPayer | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#payerService.fetchPayer(
{
payerId: params.payerId,
},
abortSignal,
);
return res.result as CrmPayer;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedCustomerPayerAddressResource extends CustomerPayerAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -1,5 +1,7 @@
export * from './country.resource';
export * from './customer-payer-address.resource';
export * from './primary-customer-card.resource';
export * from './customer-shipping-address.resource';
export * from './customer-shipping-addresses.resource';
export * from './customer.resource';
export * from './payer.resource';

View File

@@ -0,0 +1,52 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
import { CrmCustomerService } from '@domain/crm';
import { PayerDTO } from '@generated/swagger/crm-api';
@Injectable()
export class PayerResource {
#customerService = inject(CrmCustomerService);
#params = signal<{
payerId: number | undefined;
}>({
payerId: undefined,
});
params(params: { payerId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<PayerDTO | undefined> => {
if (!params.payerId) {
return undefined;
}
const res = await this.#customerService
.getPayer(params.payerId)
.toPromise();
return res?.result;
},
});
}
@Injectable({ providedIn: 'root' })
export class SelectedPayerResource extends PayerResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const payerId = tabId
? this.#customerMetadata.selectedPayerId(tabId)
: undefined;
this.params({ payerId });
});
}
}

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const FetchPayerSchema = z.object({
payerId: z.number().int().describe('Payer identifier'),
});
export type FetchPayer = z.infer<typeof FetchPayerSchema>;
export type FetchPayerInput = z.input<typeof FetchPayerSchema>;

View File

@@ -8,6 +8,7 @@ export * from './customer-feature-groups.schema';
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-payer.schema';
export * from './fetch-shipping-address.schema';
export * from './linked-record.schema';
export * from './notification-channel.schema';

View File

@@ -16,23 +16,43 @@ export const PayerSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentComment: z.string().describe('Agent comment').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
communicationDetails: CommunicationDetailsSchema.describe(
'Communication details',
).optional(),
deactivationComment: z.string().describe('Deactivation comment').optional(),
defaultPaymentPeriod: z.number().describe('Default payment period').optional(),
defaultPaymentPeriod: z
.number()
.describe('Default payment period')
.optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
isGuestAccount: z.boolean().describe('Whether guestAccount').optional(),
label: EntityContainerSchema(LabelSchema).describe('Label').optional(),
lastName: z.string().describe('Last name').optional(),
organisation: OrganisationSchema.describe('Organisation information').optional(),
organisation: OrganisationSchema.describe(
'Organisation information',
).optional(),
payerGroup: z.string().describe('Payer group').optional(),
payerNumber: z.string().describe('Unique payer account number').optional(),
payerStatus: PayerStatusSchema.describe('Current status of the payer account').optional(),
payerStatus: PayerStatusSchema.describe(
'Current status of the payer account',
).optional(),
payerType: z.nativeEnum(PayerType).describe('Payer type').optional(),
paymentTypes: z.array(PaymentSettingsSchema).describe('Payment types').optional(),
standardInvoiceText: z.string().describe('Standard invoice text').optional(),
statusChangeComment: z.string().describe('Status change comment').optional(),
paymentTypes: z
.array(PaymentSettingsSchema)
.describe('Payment types')
.optional(),
standardInvoiceText: z
.string()
.describe('Standard invoice text')
.optional(),
statusChangeComment: z
.string()
.describe('Status change comment')
.optional(),
statusComment: z.string().describe('Status comment').optional(),
title: z.string().describe('Title').optional(),
})
.extend(EntitySchema.shape);
export type CrmPayer = z.infer<typeof PayerSchema>;

View File

@@ -1,4 +1,5 @@
export * from './country.service';
export * from './crm-search.service';
export * from './crm-tab-metadata.service';
export * from './payer.service';
export * from './shipping-address.service';

View File

@@ -0,0 +1,43 @@
import { inject, Injectable } from '@angular/core';
import { PayerService as GeneratedPayerService } from '@generated/swagger/crm-api';
import {
catchResponseArgsErrorPipe,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { FetchPayerInput, FetchPayerSchema, CrmPayer } from '../schemas';
@Injectable({ providedIn: 'root' })
export class PayerService {
#payerService = inject(GeneratedPayerService);
#logger = logger(() => ({
service: 'PayerService',
}));
async fetchPayer(
params: FetchPayerInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<CrmPayer>> {
this.#logger.info('Fetching payer from API');
const { payerId } = FetchPayerSchema.parse(params);
let req$ = this.#payerService
.PayerGetPayer(payerId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched payer');
return res as ResponseArgs<CrmPayer>;
} catch (error) {
this.#logger.error('Error fetching payer', error);
return undefined as unknown as ResponseArgs<CrmPayer>;
}
}
}