feat(sync): synchronize customer adresses and orders from Hiboutik sales #1
@@ -146,16 +146,23 @@ model Customer {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
address String
|
||||
city String
|
||||
company String?
|
||||
country String
|
||||
firstName String
|
||||
hiboutikId Int @unique
|
||||
isDefault Boolean @default(false)
|
||||
lastName String
|
||||
/// Free complement (building, floor, door code...)
|
||||
other String?
|
||||
phone String?
|
||||
state String?
|
||||
zipCode String
|
||||
|
||||
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 { CustomerDatabaseRepository } from './database/customer.database.repository';
|
||||
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 { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
||||
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
||||
@@ -46,6 +48,8 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
|
||||
CategoryDatabaseRepository,
|
||||
CategoryHiboutikRepository,
|
||||
CategoryHiboutikService,
|
||||
CustomerAddressConverter,
|
||||
CustomerAddressDatabaseRepository,
|
||||
CustomerHiboutikRepository,
|
||||
CustomerDatabaseRepository,
|
||||
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 customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: 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;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -16,12 +18,16 @@ describe('CustomerHiboutikService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
service = new CustomerHiboutikService(
|
||||
customerHiboutikRepository as never,
|
||||
customerDatabaseRepository as never,
|
||||
customerConverter as never,
|
||||
customerAddressDatabaseRepository as never,
|
||||
customerAddressConverter as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -48,4 +54,57 @@ describe('CustomerHiboutikService', () => {
|
||||
|
||||
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 { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
||||
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()
|
||||
export class CustomerHiboutikService {
|
||||
@@ -11,11 +15,13 @@ export class CustomerHiboutikService {
|
||||
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||
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
|
||||
* (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() {
|
||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||
@@ -29,12 +35,12 @@ export class CustomerHiboutikService {
|
||||
}
|
||||
|
||||
let nbFailed = 0;
|
||||
let nbFailedAddresses = 0;
|
||||
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||
try {
|
||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
// retreive addresses DTOs
|
||||
// save addresses
|
||||
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
||||
// retreive orders DTOs
|
||||
// save orders
|
||||
} catch (error) {
|
||||
@@ -43,7 +49,35 @@ export class CustomerHiboutikService {
|
||||
}
|
||||
}
|
||||
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