feat(sync): synchronize customer adresses and orders from Hiboutik sales #1
@@ -146,16 +146,23 @@ model Customer {
|
|||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Address of a customer, mirrored from Hiboutik (sync not implemented yet).
|
/// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
|
||||||
model CustomerAddress {
|
model CustomerAddress {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
address String
|
address String
|
||||||
city String
|
city String
|
||||||
|
company String?
|
||||||
country String
|
country String
|
||||||
|
firstName String
|
||||||
hiboutikId Int @unique
|
hiboutikId Int @unique
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
|
lastName String
|
||||||
|
/// Free complement (building, floor, door code...)
|
||||||
|
other String?
|
||||||
|
phone String?
|
||||||
|
state String?
|
||||||
zipCode String
|
zipCode String
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data
|
|||||||
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
||||||
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
||||||
import { CustomerConverter } from './models/converter/customer.converter';
|
import { CustomerConverter } from './models/converter/customer.converter';
|
||||||
|
import { CustomerAddressConverter } from './models/converter/customerAddress.converter';
|
||||||
|
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
|
||||||
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
||||||
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
||||||
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
||||||
@@ -46,6 +48,8 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
|
|||||||
CategoryDatabaseRepository,
|
CategoryDatabaseRepository,
|
||||||
CategoryHiboutikRepository,
|
CategoryHiboutikRepository,
|
||||||
CategoryHiboutikService,
|
CategoryHiboutikService,
|
||||||
|
CustomerAddressConverter,
|
||||||
|
CustomerAddressDatabaseRepository,
|
||||||
CustomerHiboutikRepository,
|
CustomerHiboutikRepository,
|
||||||
CustomerDatabaseRepository,
|
CustomerDatabaseRepository,
|
||||||
CustomerConverter,
|
CustomerConverter,
|
||||||
|
|||||||
27
src/database/customerAddress.database.repository.ts
Normal file
27
src/database/customerAddress.database.repository.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CustomerAddress } from '../../prisma/generated/client';
|
||||||
|
import { CustomerAddressCreateInput } from '../../prisma/generated/models/CustomerAddress';
|
||||||
|
|
||||||
|
/** Database access to customer addresses. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerAddressDatabaseRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Creates or updates the address with the same `hiboutikId`. */
|
||||||
|
async upsert(address: CustomerAddressCreateInput): Promise<CustomerAddress> {
|
||||||
|
return this.prisma.customerAddress.upsert({
|
||||||
|
where: { hiboutikId: address.hiboutikId },
|
||||||
|
create: address,
|
||||||
|
update: address,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the addresses of a customer that no longer exist in Hiboutik (all of them if `keptHiboutikIds` is empty). */
|
||||||
|
async deleteMissing(customerId: string, keptHiboutikIds: number[]): Promise<number> {
|
||||||
|
const result = await this.prisma.customerAddress.deleteMany({
|
||||||
|
where: { customerId, hiboutikId: { notIn: keptHiboutikIds } },
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/models/converter/customerAddress.converter.spec.ts
Normal file
59
src/models/converter/customerAddress.converter.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { CustomerAddressConverter } from './customerAddress.converter';
|
||||||
|
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
|
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto =>
|
||||||
|
({
|
||||||
|
address_id: 7,
|
||||||
|
customers_id: 42,
|
||||||
|
first_name: 'Jane',
|
||||||
|
last_name: 'Doe',
|
||||||
|
company: 'ACME',
|
||||||
|
phone: '0600000000',
|
||||||
|
address: ' 1 rue de la Paix ',
|
||||||
|
zip_code: '75002',
|
||||||
|
city: 'Paris',
|
||||||
|
state: '',
|
||||||
|
country: 'FRA',
|
||||||
|
other: 'Bât. B',
|
||||||
|
default: 1,
|
||||||
|
...overrides,
|
||||||
|
}) as CustomerAddressHiboutikDto;
|
||||||
|
|
||||||
|
describe('CustomerAddressConverter', () => {
|
||||||
|
const converter = new CustomerAddressConverter();
|
||||||
|
|
||||||
|
it('maps a Hiboutik address', () => {
|
||||||
|
expect(converter.fromHiboutik(hiboutikAddress())).toEqual({
|
||||||
|
address: '1 rue de la Paix',
|
||||||
|
city: 'Paris',
|
||||||
|
company: 'ACME',
|
||||||
|
country: 'FRA',
|
||||||
|
firstName: 'Jane',
|
||||||
|
hiboutikId: 7,
|
||||||
|
isDefault: true,
|
||||||
|
lastName: 'Doe',
|
||||||
|
other: 'Bât. B',
|
||||||
|
phone: '0600000000',
|
||||||
|
state: null,
|
||||||
|
zipCode: '75002',
|
||||||
|
customer: { connect: { hiboutikId: 42 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ids and flags sent as strings', () => {
|
||||||
|
const address = converter.fromHiboutik(hiboutikAddress({ address_id: '8', customers_id: '43', default: '0' } as never));
|
||||||
|
expect(address.hiboutikId).toBe(8);
|
||||||
|
expect(address.isDefault).toBe(false);
|
||||||
|
expect(address.customer).toEqual({ connect: { hiboutikId: 43 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
|
||||||
|
const address = converter.fromHiboutik(
|
||||||
|
hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }),
|
||||||
|
);
|
||||||
|
expect(address.company).toBeNull();
|
||||||
|
expect(address.phone).toBeNull();
|
||||||
|
expect(address.other).toBeNull();
|
||||||
|
expect(address.firstName).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/models/converter/customerAddress.converter.ts
Normal file
32
src/models/converter/customerAddress.converter.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Converter } from './converter';
|
||||||
|
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
||||||
|
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
|
/** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */
|
||||||
|
const optional = (value: string | null | undefined): string | null => value?.trim() || null;
|
||||||
|
const required = (value: string | null | undefined): string => value?.trim() ?? '';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerAddressConverter extends Converter<CustomerAddressCreateInput, CustomerAddressHiboutikDto> {
|
||||||
|
fromHiboutik(dto: CustomerAddressHiboutikDto): CustomerAddressCreateInput {
|
||||||
|
return {
|
||||||
|
address: required(dto.address),
|
||||||
|
city: required(dto.city),
|
||||||
|
company: optional(dto.company),
|
||||||
|
country: required(dto.country),
|
||||||
|
firstName: required(dto.first_name),
|
||||||
|
hiboutikId: Number(dto.address_id),
|
||||||
|
isDefault: Number(dto.default) === 1,
|
||||||
|
lastName: required(dto.last_name),
|
||||||
|
other: optional(dto.other),
|
||||||
|
phone: optional(dto.phone),
|
||||||
|
state: optional(dto.state),
|
||||||
|
zipCode: required(dto.zip_code),
|
||||||
|
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
toHiboutik(): CustomerAddressHiboutikDto {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ describe('CustomerHiboutikService', () => {
|
|||||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
||||||
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
const customerConverter = { fromHiboutik: jest.fn() };
|
const customerConverter = { fromHiboutik: jest.fn() };
|
||||||
|
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
|
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||||
let service: CustomerHiboutikService;
|
let service: CustomerHiboutikService;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -16,12 +18,16 @@ describe('CustomerHiboutikService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||||
|
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||||
|
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||||
service = new CustomerHiboutikService(
|
service = new CustomerHiboutikService(
|
||||||
customerHiboutikRepository as never,
|
customerHiboutikRepository as never,
|
||||||
customerDatabaseRepository as never,
|
customerDatabaseRepository as never,
|
||||||
customerConverter as never,
|
customerConverter as never,
|
||||||
|
customerAddressDatabaseRepository as never,
|
||||||
|
customerAddressConverter as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,4 +54,57 @@ describe('CustomerHiboutikService', () => {
|
|||||||
|
|
||||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addresses', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the missing addresses of the customer, then upserts the others linked to it', async () => {
|
||||||
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
|
customers_id: 1,
|
||||||
|
addresses: [
|
||||||
|
{ address_id: 10, customers_id: 1 },
|
||||||
|
{ address_id: '11', customers_id: 999 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
||||||
|
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 });
|
||||||
|
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes all the addresses of a customer that has none left', async () => {
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', []);
|
||||||
|
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stored addresses when the payload has no addresses array', async () => {
|
||||||
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1 });
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerAddressDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||||
|
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps syncing the other addresses when one fails', async () => {
|
||||||
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
|
customers_id: 1,
|
||||||
|
addresses: [{ address_id: 10 }, { address_id: 11 }],
|
||||||
|
});
|
||||||
|
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
|
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
|
||||||
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
||||||
import { CustomerConverter } from '../../models/converter/customer.converter';
|
import { CustomerConverter } from '../../models/converter/customer.converter';
|
||||||
|
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
|
||||||
|
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
|
||||||
|
import { FullCustomerHiboutikDto } from '../../models/hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
import { forEachSafely } from '../../utils/sync';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerHiboutikService {
|
export class CustomerHiboutikService {
|
||||||
@@ -11,11 +15,13 @@ export class CustomerHiboutikService {
|
|||||||
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||||
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||||
private readonly customerConverter: CustomerConverter,
|
private readonly customerConverter: CustomerConverter,
|
||||||
|
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
|
||||||
|
private readonly customerAddressConverter: CustomerAddressConverter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
||||||
* (one API call per customer, as the list endpoint lacks addresses and sales).
|
* with its addresses (one API call per customer, as the list endpoint lacks addresses and sales).
|
||||||
*/
|
*/
|
||||||
async syncCustomers() {
|
async syncCustomers() {
|
||||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||||
@@ -29,12 +35,12 @@ export class CustomerHiboutikService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nbFailed = 0;
|
let nbFailed = 0;
|
||||||
|
let nbFailedAddresses = 0;
|
||||||
for (const hiboutikCustomer of hiboutikCustomers) {
|
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||||
try {
|
try {
|
||||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||||
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||||
// retreive addresses DTOs
|
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
||||||
// save addresses
|
|
||||||
// retreive orders DTOs
|
// retreive orders DTOs
|
||||||
// save orders
|
// save orders
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,7 +49,35 @@ export class CustomerHiboutikService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts
|
||||||
|
* each of them (a failing address is logged and skipped).
|
||||||
|
* @returns the number of failed addresses.
|
||||||
|
*/
|
||||||
|
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||||
|
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
|
||||||
|
if (!Array.isArray(hiboutikCustomer.addresses)) {
|
||||||
|
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.customerAddressDatabaseRepository.deleteMissing(
|
||||||
|
customerId,
|
||||||
|
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
|
||||||
|
);
|
||||||
|
return forEachSafely(
|
||||||
|
hiboutikCustomer.addresses,
|
||||||
|
// Linked to the parent customer, whatever the embedded customers_id says
|
||||||
|
(dto) =>
|
||||||
|
this.customerAddressDatabaseRepository.upsert(
|
||||||
|
this.customerAddressConverter.fromHiboutik({ ...dto, customers_id: hiboutikCustomer.customers_id }),
|
||||||
|
),
|
||||||
|
this.logger,
|
||||||
|
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user