Merge branch 'feature/5202-Praemie-Order-Confirmation-Feature' into feature/5202-Praemie

This commit is contained in:
Lorenz Hilpert
2025-10-22 15:24:50 +02:00
348 changed files with 108301 additions and 42068 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,4 +3,5 @@ export * from './lib/constants';
export * from './lib/models';
export * from './lib/resources';
export * from './lib/helpers';
export * from './lib/schemas';
export * from './lib/services';

View File

@@ -1,7 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { CrmSearchService } from '../services/crm-search.service';
import { FetchCustomerInput } from '../schemas';
import { Customer } from '../models';
import { FetchCustomerInput, Customer } from '../schemas';
@Injectable({ providedIn: 'root' })
export class CustomerFacade {

View File

@@ -0,0 +1,498 @@
import { describe, it, expect } from 'vitest';
import {
areAddresseesEqual,
areBranchesEqual,
deduplicateAddressees,
deduplicateBranches,
} from './deduplicate-addressees.helper';
import {
DisplayAddresseeDTO,
DisplayBranchDTO,
AddressDTO,
} from '@generated/swagger/oms-api';
describe('areAddresseesEqual', () => {
const sampleAddress: AddressDTO = {
street: 'Teststraße',
streetNumber: '123',
city: 'Berlin',
zipCode: '10115',
country: 'DEU',
};
const createAddressee = (
firstName: string,
lastName: string,
address?: AddressDTO,
): DisplayAddresseeDTO => ({
firstName,
lastName,
address,
gender: 0, // NotSet
});
it('should return true when both addressees are undefined', () => {
// Act
const result = areAddresseesEqual(undefined, undefined);
// Assert
expect(result).toBe(true);
});
it('should return false when only first addressee is undefined', () => {
// Arrange
const addressee = createAddressee('John', 'Doe', sampleAddress);
// Act
const result = areAddresseesEqual(undefined, addressee);
// Assert
expect(result).toBe(false);
});
it('should return false when only second addressee is undefined', () => {
// Arrange
const addressee = createAddressee('John', 'Doe', sampleAddress);
// Act
const result = areAddresseesEqual(addressee, undefined);
// Assert
expect(result).toBe(false);
});
it('should return true when addressees have same name and address', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe', { ...sampleAddress });
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(true);
});
it('should return false when addressees have different first names', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('Jane', 'Doe', sampleAddress);
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(false);
});
it('should return false when addressees have different last names', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Smith', sampleAddress);
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(false);
});
it('should return false when addressees have different addresses', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe', {
...sampleAddress,
street: 'Other Street',
});
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(false);
});
it('should return true when both addressees have no address', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe');
const addressee2 = createAddressee('John', 'Doe');
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(true);
});
it('should return false when one has address and other does not', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe');
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(false);
});
it('should handle empty string names', () => {
// Arrange
const addressee1 = createAddressee('', '', sampleAddress);
const addressee2 = createAddressee('', '', sampleAddress);
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(true);
});
it('should treat undefined names as empty strings', () => {
// Arrange
const addressee1: DisplayAddresseeDTO = {
gender: 0, // NotSet
address: sampleAddress,
};
const addressee2: DisplayAddresseeDTO = {
firstName: '',
lastName: '',
gender: 0, // NotSet
address: sampleAddress,
};
// Act
const result = areAddresseesEqual(addressee1, addressee2);
// Assert
expect(result).toBe(true);
});
});
describe('deduplicateAddressees', () => {
const sampleAddress: AddressDTO = {
street: 'Teststraße',
streetNumber: '123',
city: 'Berlin',
zipCode: '10115',
country: 'DEU',
};
const createAddressee = (
firstName: string,
lastName: string,
address?: AddressDTO,
): DisplayAddresseeDTO => ({
firstName,
lastName,
address,
gender: 0, // NotSet
});
it('should return empty array when input is empty', () => {
// Arrange
const addressees: DisplayAddresseeDTO[] = [];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toEqual([]);
});
it('should filter out undefined values', () => {
// Arrange
const addressee = createAddressee('John', 'Doe', sampleAddress);
const addressees = [undefined, addressee, undefined];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual(addressee);
});
it('should return single item when array has one addressee', () => {
// Arrange
const addressee = createAddressee('John', 'Doe', sampleAddress);
const addressees = [addressee];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual(addressee);
});
it('should return all items when no duplicates exist', () => {
// Arrange
const addressees = [
createAddressee('John', 'Doe', sampleAddress),
createAddressee('Jane', 'Smith', sampleAddress),
createAddressee('Bob', 'Johnson', {
...sampleAddress,
street: 'Other Street',
}),
];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(3);
});
it('should remove duplicate addressees keeping first occurrence', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe', { ...sampleAddress });
const addressees = [addressee1, addressee2];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toBe(addressee1); // First occurrence kept
});
it('should handle multiple duplicates and keep only first', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe', { ...sampleAddress });
const addressee3 = createAddressee('John', 'Doe', { ...sampleAddress });
const addressees = [addressee1, addressee2, addressee3];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toBe(addressee1);
});
it('should not consider different names but same address as duplicates', () => {
// Arrange
const addressees = [
createAddressee('John', 'Doe', sampleAddress),
createAddressee('Jane', 'Doe', sampleAddress),
];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(2);
});
it('should not consider same names but different addresses as duplicates', () => {
// Arrange
const addressees = [
createAddressee('John', 'Doe', sampleAddress),
createAddressee('John', 'Doe', {
...sampleAddress,
street: 'Other Street',
}),
];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(2);
});
it('should handle complex scenario with mixed duplicates and unique items', () => {
// Arrange
const addressee1 = createAddressee('John', 'Doe', sampleAddress);
const addressee2 = createAddressee('John', 'Doe', { ...sampleAddress }); // Duplicate of 1
const addressee3 = createAddressee('Jane', 'Smith', sampleAddress);
const addressee4 = createAddressee('Jane', 'Smith', { ...sampleAddress }); // Duplicate of 3
const addressee5 = createAddressee('Bob', 'Johnson', sampleAddress);
const addressees = [
addressee1,
addressee2,
addressee3,
addressee4,
addressee5,
undefined,
];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(3);
expect(result[0]).toBe(addressee1);
expect(result[1]).toBe(addressee3);
expect(result[2]).toBe(addressee5);
});
it('should preserve order of first occurrences', () => {
// Arrange
const addressee1 = createAddressee('Alice', 'Wonder', sampleAddress);
const addressee2 = createAddressee('Bob', 'Builder', sampleAddress);
const addressee3 = createAddressee('Alice', 'Wonder', { ...sampleAddress }); // Duplicate
const addressees = [addressee1, addressee2, addressee3];
// Act
const result = deduplicateAddressees(addressees);
// Assert
expect(result).toHaveLength(2);
expect(result[0]).toBe(addressee1);
expect(result[1]).toBe(addressee2);
});
});
describe('areBranchesEqual', () => {
const sampleAddress: AddressDTO = {
street: 'Teststraße',
streetNumber: '123',
city: 'Berlin',
zipCode: '10115',
country: 'DEU',
};
const createBranch = (
name: string,
address?: AddressDTO,
): DisplayBranchDTO => ({
name,
address,
});
it('should return true when both branches are undefined', () => {
// Act
const result = areBranchesEqual(undefined, undefined);
// Assert
expect(result).toBe(true);
});
it('should return false when only first branch is undefined', () => {
// Arrange
const branch = createBranch('Branch 1', sampleAddress);
// Act
const result = areBranchesEqual(undefined, branch);
// Assert
expect(result).toBe(false);
});
it('should return true when branches have same name and address', () => {
// Arrange
const branch1 = createBranch('Branch 1', sampleAddress);
const branch2 = createBranch('Branch 1', { ...sampleAddress });
// Act
const result = areBranchesEqual(branch1, branch2);
// Assert
expect(result).toBe(true);
});
it('should return false when branches have different names', () => {
// Arrange
const branch1 = createBranch('Branch 1', sampleAddress);
const branch2 = createBranch('Branch 2', sampleAddress);
// Act
const result = areBranchesEqual(branch1, branch2);
// Assert
expect(result).toBe(false);
});
it('should return false when branches have different addresses', () => {
// Arrange
const branch1 = createBranch('Branch 1', sampleAddress);
const branch2 = createBranch('Branch 1', {
...sampleAddress,
street: 'Other Street',
});
// Act
const result = areBranchesEqual(branch1, branch2);
// Assert
expect(result).toBe(false);
});
});
describe('deduplicateBranches', () => {
const sampleAddress: AddressDTO = {
street: 'Teststraße',
streetNumber: '123',
city: 'Berlin',
zipCode: '10115',
country: 'DEU',
};
const createBranch = (
name: string,
address?: AddressDTO,
): DisplayBranchDTO => ({
name,
address,
});
it('should return empty array when input is empty', () => {
// Arrange
const branches: DisplayBranchDTO[] = [];
// Act
const result = deduplicateBranches(branches);
// Assert
expect(result).toEqual([]);
});
it('should filter out undefined values', () => {
// Arrange
const branch = createBranch('Branch 1', sampleAddress);
const branches = [undefined, branch, undefined];
// Act
const result = deduplicateBranches(branches);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toEqual(branch);
});
it('should remove duplicate branches keeping first occurrence', () => {
// Arrange
const branch1 = createBranch('Branch 1', sampleAddress);
const branch2 = createBranch('Branch 1', { ...sampleAddress });
const branches = [branch1, branch2];
// Act
const result = deduplicateBranches(branches);
// Assert
expect(result).toHaveLength(1);
expect(result[0]).toBe(branch1);
});
it('should return all items when no duplicates exist', () => {
// Arrange
const branches = [
createBranch('Branch 1', sampleAddress),
createBranch('Branch 2', sampleAddress),
createBranch('Branch 3', {
...sampleAddress,
street: 'Other Street',
}),
];
// Act
const result = deduplicateBranches(branches);
// Assert
expect(result).toHaveLength(3);
});
});

View File

@@ -0,0 +1,207 @@
import {
DisplayAddresseeDTO,
DisplayBranchDTO,
} from '@generated/swagger/oms-api';
/**
* Internal model representing an entity with name and address for comparison.
* This allows for type-safe comparison without casting.
*/
interface EntityWithNameAndAddress {
readonly name: string;
readonly address: unknown;
}
/**
* Converts an addressee-like object to a comparable entity.
*/
function toComparableAddressee(
addressee: Pick<DisplayAddresseeDTO, 'firstName' | 'lastName' | 'address'>,
): EntityWithNameAndAddress {
const firstName = addressee.firstName ?? '';
const lastName = addressee.lastName ?? '';
const name = `${firstName}|${lastName}`.trim();
return {
name,
address: addressee.address ?? null,
};
}
/**
* Converts a branch-like object to a comparable entity.
*/
function toComparableBranch(
branch: Pick<DisplayBranchDTO, 'name' | 'address'>,
): EntityWithNameAndAddress {
return {
name: branch.name ?? '',
address: branch.address ?? null,
};
}
/**
* Compares two entities for equality based on name and address.
*/
function areEntitiesEqual(
entity1: EntityWithNameAndAddress,
entity2: EntityWithNameAndAddress,
): boolean {
if (entity1.name !== entity2.name) {
return false;
}
const address1Str = JSON.stringify(entity1.address);
const address2Str = JSON.stringify(entity2.address);
return address1Str === address2Str;
}
/**
* Compares two DisplayAddresseeDTO objects for equality based on name and address.
* Two addressees are considered equal if they have the same firstName, lastName, and address.
*
* @param addressee1 - First addressee to compare
* @param addressee2 - Second addressee to compare
* @returns true if addressees are equal, false otherwise
*/
export function areAddresseesEqual(
addressee1: DisplayAddresseeDTO | undefined,
addressee2: DisplayAddresseeDTO | undefined,
): boolean {
if (!addressee1 && !addressee2) {
return true;
}
if (!addressee1 || !addressee2) {
return false;
}
return areEntitiesEqual(
toComparableAddressee(addressee1),
toComparableAddressee(addressee2),
);
}
/**
* Compares two DisplayBranchDTO objects for equality based on name and address.
* Two branches are considered equal if they have the same name and address.
*
* @param branch1 - First branch to compare
* @param branch2 - Second branch to compare
* @returns true if branches are equal, false otherwise
*/
export function areBranchesEqual(
branch1: DisplayBranchDTO | undefined,
branch2: DisplayBranchDTO | undefined,
): boolean {
if (!branch1 && !branch2) {
return true;
}
if (!branch1 || !branch2) {
return false;
}
return areEntitiesEqual(
toComparableBranch(branch1),
toComparableBranch(branch2),
);
}
/**
* Type guard to check if an object can be compared as an addressee.
*/
function hasAddresseeShape(
obj: unknown,
): obj is Pick<DisplayAddresseeDTO, 'firstName' | 'lastName' | 'address'> {
return (
obj !== null &&
typeof obj === 'object' &&
('firstName' in obj || 'lastName' in obj || 'address' in obj)
);
}
/**
* Type guard to check if an object can be compared as a branch.
*/
function hasBranchShape(
obj: unknown,
): obj is Pick<DisplayBranchDTO, 'name' | 'address'> {
return (
obj !== null &&
typeof obj === 'object' &&
('name' in obj || 'address' in obj)
);
}
/**
* Removes duplicate addressees from an array, keeping only the first occurrence.
* Two addressees are considered duplicates if they have the same firstName, lastName, and address.
*
* @param addressees - Array of addressees to deduplicate
* @returns Deduplicated array with only first occurrence of each unique addressee
*/
export function deduplicateAddressees<
T extends Pick<DisplayAddresseeDTO, 'firstName' | 'lastName' | 'address'>,
>(addressees: readonly (T | undefined)[]): T[] {
const result: T[] = [];
const seen: T[] = [];
for (const addressee of addressees) {
if (!addressee || !hasAddresseeShape(addressee)) {
continue;
}
const isDuplicate = seen.some((seenAddressee) =>
hasAddresseeShape(seenAddressee)
? areEntitiesEqual(
toComparableAddressee(seenAddressee),
toComparableAddressee(addressee),
)
: false,
);
if (!isDuplicate) {
result.push(addressee);
seen.push(addressee);
}
}
return result;
}
/**
* Removes duplicate branches from an array, keeping only the first occurrence.
* Two branches are considered duplicates if they have the same name and address.
*
* @param branches - Array of branches to deduplicate
* @returns Deduplicated array with only first occurrence of each unique branch
*/
export function deduplicateBranches<
T extends Pick<DisplayBranchDTO, 'name' | 'address'>,
>(branches: readonly (T | undefined)[]): T[] {
const result: T[] = [];
const seen: T[] = [];
for (const branch of branches) {
if (!branch || !hasBranchShape(branch)) {
continue;
}
const isDuplicate = seen.some((seenBranch) =>
hasBranchShape(seenBranch)
? areEntitiesEqual(
toComparableBranch(seenBranch),
toComparableBranch(branch),
)
: false,
);
if (!isDuplicate) {
result.push(branch);
seen.push(branch);
}
}
return result;
}

View File

@@ -1,2 +1,8 @@
export {
areAddresseesEqual,
areBranchesEqual,
deduplicateAddressees,
deduplicateBranches,
} from './deduplicate-addressees.helper';
export * from './get-customer-name.component';
export * from './get-primary-bonus-card.helper';

View File

@@ -1,7 +0,0 @@
import { AssignedPayerDTO } from '@generated/swagger/crm-api';
import { Payer } from './payer';
import { EntityContainer } from '@isa/common/data-access';
export type AssignedPayer = AssignedPayerDTO & {
payer: EntityContainer<Payer>;
};

View File

@@ -1,8 +0,0 @@
import { CustomerDTO } from '@generated/swagger/crm-api';
import { CustomerType } from './customer-type';
import { AssignedPayer } from './assigned-payer';
export interface Customer extends CustomerDTO {
customerType: CustomerType;
payers: Array<AssignedPayer>;
}

View File

@@ -1,7 +1,4 @@
export * from './assigned-payer';
export * from './bonus-card-info.model';
export * from './country';
export * from './customer-type';
export * from './customer.model';
export * from './payer';
export * from './shipping-address.model';

View File

@@ -1,3 +0,0 @@
import { ShippingAddressDTO } from '@generated/swagger/crm-api';
export type ShippingAddress = ShippingAddressDTO;

View File

@@ -1,54 +1,57 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
shippingAddressId: number | undefined;
}>({
shippingAddressId: undefined,
});
params(params: { shippingAddressId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress | undefined> => {
if (!params.shippingAddressId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchShippingAddress(
{
shippingAddressId: params.shippingAddressId,
},
abortSignal,
);
return res.result as ShippingAddress;
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shippingAddressId = tabId
? this.#customerMetadata.selectedShippingAddressId(tabId)
: undefined;
this.params({ shippingAddressId });
});
}
}
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../schemas';
@Injectable()
export class CustomerShippingAddressResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
shippingAddressId: number | undefined;
}>({
shippingAddressId: undefined,
});
params(params: { shippingAddressId?: number }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({
params,
abortSignal,
}): Promise<ShippingAddress | undefined> => {
if (!params.shippingAddressId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchShippingAddress(
{
shippingAddressId: params.shippingAddressId,
},
abortSignal,
);
return res.result as ShippingAddress;
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressResource extends CustomerShippingAddressResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const shippingAddressId = tabId
? this.#customerMetadata.selectedShippingAddressId(tabId)
: undefined;
this.params({ shippingAddressId });
});
}
}

View File

@@ -1,58 +1,66 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../models';
@Injectable()
export class CustomerShippingAddressesResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
customerId: number | undefined;
take?: number | null;
skip?: number | null;
}>({
customerId: undefined,
});
params(params: { customerId?: number; take?: number | null; skip?: number | null }) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({ params, abortSignal }): Promise<ShippingAddress[] | undefined> => {
if (!params.customerId) {
return undefined;
}
const res = await this.#shippingAddressService.fetchCustomerShippingAddresses(
{
customerId: params.customerId,
take: params.take,
skip: params.skip,
},
abortSignal,
);
return res.result as ShippingAddress[];
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const customerId = tabId
? this.#customerMetadata.selectedCustomerId(tabId)
: undefined;
this.params({ customerId });
});
}
}
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmTabMetadataService, ShippingAddressService } from '../services';
import { TabService } from '@isa/core/tabs';
import { ShippingAddress } from '../schemas';
@Injectable()
export class CustomerShippingAddressesResource {
#shippingAddressService = inject(ShippingAddressService);
#params = signal<{
customerId: number | undefined;
take?: number | null;
skip?: number | null;
}>({
customerId: undefined,
});
params(params: {
customerId?: number;
take?: number | null;
skip?: number | null;
}) {
this.#params.update((p) => ({ ...p, ...params }));
}
readonly resource = resource({
params: () => this.#params(),
loader: async ({
params,
abortSignal,
}): Promise<ShippingAddress[] | undefined> => {
if (!params.customerId) {
return undefined;
}
const res =
await this.#shippingAddressService.fetchCustomerShippingAddresses(
{
customerId: params.customerId,
take: params.take,
skip: params.skip,
},
abortSignal,
);
return res.result as ShippingAddress[];
},
});
}
@Injectable()
export class SelectedCustomerShippingAddressesResource extends CustomerShippingAddressesResource {
#tabId = inject(TabService).activatedTabId;
#customerMetadata = inject(CrmTabMetadataService);
constructor() {
super();
effect(() => {
const tabId = this.#tabId();
const customerId = tabId
? this.#customerMetadata.selectedCustomerId(tabId)
: undefined;
this.params({ customerId });
});
}
}

View File

@@ -1,7 +1,7 @@
import { effect, inject, Injectable, resource, signal } from '@angular/core';
import { CrmSearchService, CrmTabMetadataService } from '../services';
import { TabService } from '@isa/core/tabs';
import { Customer } from '../models';
import { Customer } from '../schemas';
@Injectable()
export class CustomerResource {

View File

@@ -0,0 +1,11 @@
import { EntityContainerSchema } from '@isa/common/data-access';
import { z } from 'zod';
import { PayerSchema } from './payer.schema';
export const AssignedPayerSchema = z.object({
assignedToCustomer: z.string().describe('Assigned to customer').optional(),
isDefault: z.string().describe('Whether this is the default').optional(),
payer: EntityContainerSchema(PayerSchema).describe('Payer information').optional(),
});
export type AssignedPayer = z.infer<typeof AssignedPayerSchema>;

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
/**
* Schema for AttributeDTO
* Represents attribute data with date fields as strings (ISO format)
*
* Note: The API returns start/stop as string (not Date objects).
* If date parsing is needed, use z.coerce.date() or parse in application logic.
*/
export const AttributeSchema = z.object({
dataType: z.number().describe('Data type'),
formatValidator: z.string().describe('Format validator').optional(),
group: z.string().describe('Group').optional(),
key: z.string().describe('Key'),
name: z.string().describe('Name').optional(),
start: z.string().describe('Start').optional(),
stop: z.string().describe('Stop').optional(),
value: z.string().describe('Value').optional(),
});

View File

@@ -0,0 +1,22 @@
import { EntitySchema } from '@isa/common/data-access';
import { z } from 'zod';
/**
* Schema for BonusCardDTO
* Represents bonus card information with proper type matching
*
* Note: cardProvider is a number in the API, and dates are returned as strings
*/
export const BonusCardSchema = z
.object({
bonusValue: z.number().describe('Bonus value').optional(),
cardNumber: z.string().describe('Card number').optional(),
cardProvider: z.number().describe('Card provider').optional(),
isLocked: z.boolean().describe('Whether locked').optional(),
isPaymentEnabled: z.boolean().describe('Whether paymentEnabled').optional(),
markedAsLost: z.string().describe('Marked as lost').optional(),
suspensionComment: z.string().describe('Suspension comment').optional(),
validFrom: z.string().describe('Validity start date').optional(),
validThrough: z.string().describe('Valid through').optional(),
})
.extend(EntitySchema.shape);

View File

@@ -0,0 +1,21 @@
import {
AddressSchema,
EntityContainerSchema,
EntitySchema,
LabelSchema,
} from '@isa/common/data-access';
import { number, z } from 'zod';
export const BranchSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
banchNumber: z.string().describe('Banch number').optional(),
branchType: z.number().describe('Branch type'),
isOnline: z.boolean().describe('Whether online').optional(),
key: z.string().describe('Key').optional(),
label: EntityContainerSchema(LabelSchema).describe('Label').optional(),
name: z.string().describe('Name').optional(),
parent: z.number().describe('Parent').optional(),
shortName: z.string().describe('Short name').optional(),
})
.extend(EntitySchema.shape);

View File

@@ -0,0 +1,96 @@
import {
AddressSchema,
CommunicationDetailsSchema,
EntityContainerSchema,
EntitySchema,
GenderSchema,
KeyValueOfStringAndStringSchema,
LabelSchema,
NotificationChannelSchema,
OrganisationSchema,
} from '@isa/common/data-access';
import { z } from 'zod';
import { CustomerType } from '../models';
import { AssignedPayerSchema } from './assigned-payer.schema';
import { AttributeSchema } from './attribute.schema';
import { BonusCardSchema } from './bonus-card.schema';
import { BranchSchema } from './branch.schema';
import { LinkedRecordSchema } from './linked-record.schema';
import { ShippingAddressSchema } from './shipping-address.schema';
import { UserSchema } from './user.schema';
export const CustomerSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentComment: z.string().describe('Agent comment').optional(),
attributes: z
.array(EntityContainerSchema(AttributeSchema))
.describe('Attribute list')
.optional(),
bonusCard: EntityContainerSchema(BonusCardSchema)
.describe('Bonus card information')
.optional(),
campaignCode: z.string().describe('Campaign code').optional(),
clientChannel: z.number().describe('Client channel').optional(),
communicationDetails: CommunicationDetailsSchema.describe(
'Communication details',
).optional(),
createdInBranch: EntityContainerSchema(BranchSchema)
.describe('Created in branch')
.optional(),
customerGroup: z.string().describe('Customer group').optional(),
customerNumber: z.string().describe('Customer number').optional(),
customerStatus: z.number().describe('Customer status').optional(),
customerType: z.nativeEnum(CustomerType).describe('Customer type'),
dateOfBirth: z.string().describe('Date of birth').optional(),
deactivationComment: z.string().describe('Deactivation comment').optional(),
features: z
.array(KeyValueOfStringAndStringSchema)
.describe('Features')
.optional(),
fetchOnDeliveryNote: z
.boolean()
.describe('Fetch on delivery note')
.optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
hasOnlineAccount: z
.boolean()
.describe('Whether has onlineAccount')
.optional(),
isGuestAccount: z.boolean().describe('Whether guestAccount').optional(),
label: EntityContainerSchema(LabelSchema).describe('Label').optional(),
lastName: z.string().describe('Last name').optional(),
linkedRecords: z
.array(LinkedRecordSchema)
.describe('List of linked records')
.optional(),
notificationChannels: NotificationChannelSchema.describe(
'Notification channels',
).optional(),
orderCount: z.number().describe('Number of orders').optional(),
organisation: OrganisationSchema.describe(
'Organisation information',
).optional(),
payers: z.array(AssignedPayerSchema).describe('Payers').optional(),
preferredPaymentType: z
.number()
.describe('PreferredPayment type')
.optional(),
shippingAddresses: z
.array(EntityContainerSchema(ShippingAddressSchema))
.describe('Shipping addresses')
.optional(),
statusChangeComment: z
.string()
.describe('Status change comment')
.optional(),
statusComment: z.string().describe('Status comment').optional(),
title: z.string().describe('Title').optional(),
user: EntityContainerSchema(UserSchema)
.describe('User information')
.optional(),
})
.extend(EntitySchema.shape);
export type Customer = z.infer<typeof CustomerSchema>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const FetchCustomerCardsSchema = z.object({
customerId: z.number().int(),
customerId: z.number().int().describe('Unique customer identifier'),
});
export type FetchCustomerCards = z.infer<typeof FetchCustomerCardsSchema>;

View File

@@ -1,9 +1,9 @@
import { z } from 'zod';
export const FetchCustomerShippingAddressesSchema = z.object({
customerId: z.number().int(),
take: z.number().int().optional().nullable(),
skip: z.number().int().optional().nullable(),
customerId: z.number().int().describe('Unique customer identifier'),
take: z.number().int().optional().describe('Number of items to return per page').nullable(),
skip: z.number().int().optional().describe('Number of items to skip for pagination').nullable(),
});
export type FetchCustomerShippingAddresses = z.infer<typeof FetchCustomerShippingAddressesSchema>;

View File

@@ -1,8 +1,8 @@
import { z } from 'zod';
export const FetchCustomerSchema = z.object({
customerId: z.number().int(),
eagerLoading: z.number().optional(),
customerId: z.number().int().describe('Unique customer identifier'),
eagerLoading: z.number().describe('Eager loading').optional(),
});
export type FetchCustomer = z.infer<typeof FetchCustomerSchema>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const FetchShippingAddressSchema = z.object({
shippingAddressId: z.number().int(),
shippingAddressId: z.number().int().describe('ShippingAddress identifier'),
});
export type FetchShippingAddress = z.infer<typeof FetchShippingAddressSchema>;

View File

@@ -1,4 +1,16 @@
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-shipping-address.schema';
export * from './assigned-payer.schema';
export * from './attribute.schema';
export * from './bonus-card.schema';
export * from './branch.schema';
export * from './customer.schema';
export * from './fetch-customer-cards.schema';
export * from './fetch-customer-shipping-addresses.schema';
export * from './fetch-customer.schema';
export * from './fetch-shipping-address.schema';
export * from './linked-record.schema';
export * from './notification-channel.schema';
export * from './payer-status.schema';
export * from './payer.schema';
export * from './payment-settings.schema';
export * from './shipping-address.schema';
export * from './user.schema';

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const LinkedRecordSchema = z.object({
isSource: z.boolean().describe('Whether source').optional(),
number: z.string().describe('Number').optional(),
pk: z.string().describe('Pk').optional(),
repository: z.string().describe('Repository').optional(),
});

View File

@@ -0,0 +1,11 @@
/**
* @deprecated This schema has been moved to @isa/common/data-access.
* Please update imports to use:
* import { NotificationChannelSchema, NotificationChannel } from '@isa/common/data-access';
*
* This re-export will be removed in a future version.
*/
export {
NotificationChannelSchema,
NotificationChannel,
} from '@isa/common/data-access';

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const PayerStatus = {
NotSet: 0,
Blocked: 1,
Check: 2,
LowDegreeOfCreditworthiness: 4,
Dunning1: 8,
Dunning2: 16,
} as const;
export const PayerStatusSchema = z.nativeEnum(PayerStatus).describe('Payer status');
export type PayerStatus = z.infer<typeof PayerStatusSchema>;

View File

@@ -0,0 +1,38 @@
import {
AddressSchema,
CommunicationDetailsSchema,
EntityContainerSchema,
EntitySchema,
GenderSchema,
LabelSchema,
OrganisationSchema,
PayerType,
} from '@isa/common/data-access';
import { z } from 'zod';
import { PayerStatusSchema } from './payer-status.schema';
import { PaymentSettingsSchema } from './payment-settings.schema';
export const PayerSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentComment: z.string().describe('Agent comment').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
deactivationComment: z.string().describe('Deactivation comment').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(),
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(),
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(),
statusComment: z.string().describe('Status comment').optional(),
title: z.string().describe('Title').optional(),
})
.extend(EntitySchema.shape);

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const PaymentSettingsSchema = z.object({
allow: z.string().describe('Allow').optional(),
channel: z.string().describe('Communication channel').optional(),
denaiedReason: z.string().describe('Denaied reason').optional(),
deny: z.string().describe('Deny').optional(),
paymentType: z.number().int().positive().describe('Payment type').optional(),
});

View File

@@ -0,0 +1,26 @@
import {
AddressSchema,
CommunicationDetailsSchema,
EntitySchema,
GenderSchema,
OrganisationSchema,
} from '@isa/common/data-access';
import z from 'zod';
export const ShippingAddressSchema = z
.object({
address: AddressSchema.describe('Address').optional(),
agentomment: z.string().describe('Agentomment').optional(),
communicationDetails: CommunicationDetailsSchema.describe('Communication details').optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
lastName: z.string().describe('Last name').optional(),
organisation: OrganisationSchema.describe('Organisation information').optional(),
title: z.string().describe('Title').optional(),
type: z.number().describe('Type').optional(),
validated: z.string().describe('Validated').optional(),
validationResult: z.number().describe('Validation result').optional(),
})
.extend(EntitySchema.shape);
export type ShippingAddress = z.infer<typeof ShippingAddressSchema>;

View File

@@ -0,0 +1,13 @@
import { EntitySchema, GenderSchema } from '@isa/common/data-access';
import { z } from 'zod';
export const UserSchema = z
.object({
email: z.string().email().describe('Email address').optional(),
firstName: z.string().describe('First name').optional(),
gender: GenderSchema.describe('Gender').optional(),
lastName: z.string().describe('Last name').optional(),
name: z.string().describe('Name').optional(),
title: z.string().describe('Title').optional(),
})
.extend(EntitySchema.shape);

View File

@@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { CustomerService } from '@generated/swagger/crm-api';
import {
Customer,
FetchCustomerCardsInput,
FetchCustomerCardsSchema,
FetchCustomerInput,
@@ -12,7 +13,7 @@ import {
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { BonusCardInfo, Customer } from '../models';
import { BonusCardInfo } from '../models';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })

View File

@@ -1,79 +1,79 @@
import { inject, Injectable } from '@angular/core';
import { ShippingAddressService as GeneratedShippingAddressService } from '@generated/swagger/crm-api';
import {
FetchCustomerShippingAddressesInput,
FetchCustomerShippingAddressesSchema,
FetchShippingAddressInput,
FetchShippingAddressSchema,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { ShippingAddress } from '../models';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class ShippingAddressService {
#shippingAddressService = inject(GeneratedShippingAddressService);
#logger = logger(() => ({
service: 'ShippingAddressService',
}));
async fetchCustomerShippingAddresses(
params: FetchCustomerShippingAddressesInput,
abortSignal?: AbortSignal,
): Promise<ListResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching customer shipping addresses from API');
const { customerId, take, skip } =
FetchCustomerShippingAddressesSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingAddresses({ customerId, take, skip })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer shipping addresses');
return res as ListResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching customer shipping addresses', error);
return {
result: [],
totalCount: 0,
} as unknown as ListResponseArgs<ShippingAddress>;
}
}
async fetchShippingAddress(
params: FetchShippingAddressInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching shipping address from API');
const { shippingAddressId } = FetchShippingAddressSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingaddress(shippingAddressId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched shipping address');
return res as ResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching shipping address', error);
return undefined as unknown as ResponseArgs<ShippingAddress>;
}
}
}
import { inject, Injectable } from '@angular/core';
import { ShippingAddressService as GeneratedShippingAddressService } from '@generated/swagger/crm-api';
import {
FetchCustomerShippingAddressesInput,
FetchCustomerShippingAddressesSchema,
FetchShippingAddressInput,
FetchShippingAddressSchema,
ShippingAddress,
} from '../schemas';
import {
catchResponseArgsErrorPipe,
ListResponseArgs,
ResponseArgs,
takeUntilAborted,
} from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
@Injectable({ providedIn: 'root' })
export class ShippingAddressService {
#shippingAddressService = inject(GeneratedShippingAddressService);
#logger = logger(() => ({
service: 'ShippingAddressService',
}));
async fetchCustomerShippingAddresses(
params: FetchCustomerShippingAddressesInput,
abortSignal?: AbortSignal,
): Promise<ListResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching customer shipping addresses from API');
const { customerId, take, skip } =
FetchCustomerShippingAddressesSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingAddresses({ customerId, take, skip })
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched customer shipping addresses');
return res as ListResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching customer shipping addresses', error);
return {
result: [],
totalCount: 0,
} as unknown as ListResponseArgs<ShippingAddress>;
}
}
async fetchShippingAddress(
params: FetchShippingAddressInput,
abortSignal?: AbortSignal,
): Promise<ResponseArgs<ShippingAddress>> {
this.#logger.info('Fetching shipping address from API');
const { shippingAddressId } = FetchShippingAddressSchema.parse(params);
let req$ = this.#shippingAddressService
.ShippingAddressGetShippingaddress(shippingAddressId)
.pipe(catchResponseArgsErrorPipe());
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
try {
const res = await firstValueFrom(req$);
this.#logger.debug('Successfully fetched shipping address');
return res as ResponseArgs<ShippingAddress>;
} catch (error) {
this.#logger.error('Error fetching shipping address', error);
return undefined as unknown as ResponseArgs<ShippingAddress>;
}
}
}