feat(sync): synchronize customer adresses and orders from Hiboutik sales (#1)
This commit is contained in:
@@ -2,9 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||
|
||||
describe('CustomerHiboutikService', () => {
|
||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: 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) };
|
||||
const saleHiboutikRepository = { getSaleById: jest.fn() };
|
||||
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
|
||||
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
let service: CustomerHiboutikService;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -16,12 +21,21 @@ 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);
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
||||
service = new CustomerHiboutikService(
|
||||
customerHiboutikRepository as never,
|
||||
customerDatabaseRepository as never,
|
||||
customerConverter as never,
|
||||
customerAddressDatabaseRepository as never,
|
||||
customerAddressConverter as never,
|
||||
saleHiboutikRepository as never,
|
||||
orderDatabaseRepository as never,
|
||||
orderConverter as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -48,4 +62,145 @@ 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('fetches the company details of addresses with a company only', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [
|
||||
{ address_id: 10, company: 'ACME' },
|
||||
{ address_id: 11, company: ' ' },
|
||||
],
|
||||
});
|
||||
customerHiboutikRepository.getAddressById.mockResolvedValue({
|
||||
address_id: 10,
|
||||
tax_number: 'FR123',
|
||||
company_number: '123',
|
||||
legal_status: 'SAS',
|
||||
updated_at: '2026-11-01 10:00:00',
|
||||
});
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledTimes(1);
|
||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledWith(10);
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({
|
||||
address_id: 10,
|
||||
company: 'ACME',
|
||||
customers_id: 1,
|
||||
tax_number: 'FR123',
|
||||
company_number: '123',
|
||||
legal_status: 'SAS',
|
||||
updated_at: '2026-11-01 10:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('still syncs an address whose company details cannot be fetched', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [{ address_id: 10, company: 'ACME' }] });
|
||||
customerHiboutikRepository.getAddressById.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ address_id: 10, company: 'ACME', customers_id: 1 });
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orders', () => {
|
||||
beforeEach(() => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [],
|
||||
sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches and upserts the sales not yet stored as completed, linked to the customer', async () => {
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set([6]));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]);
|
||||
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
||||
});
|
||||
|
||||
it('ignores quotations', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockResolvedValueOnce({ sale_id: 5, quotation: 1 });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps syncing the other sales when one fails', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips orders when the payload has no sales array', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(saleHiboutikRepository.getSaleById).not.toHaveBeenCalled();
|
||||
expect(orderDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,17 @@ 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 {
|
||||
CustomerAddressDetailsHiboutikDto,
|
||||
CustomerAddressHiboutikDto,
|
||||
FullCustomerHiboutikDto,
|
||||
} from '../../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository';
|
||||
import { OrderDatabaseRepository } from '../../database/order.database.repository';
|
||||
import { OrderConverter } from '../../models/converter/order.converter';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerHiboutikService {
|
||||
@@ -11,11 +22,17 @@ export class CustomerHiboutikService {
|
||||
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||
private readonly customerConverter: CustomerConverter,
|
||||
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
|
||||
private readonly customerAddressConverter: CustomerAddressConverter,
|
||||
private readonly saleHiboutikRepository: SaleHiboutikRepository,
|
||||
private readonly orderDatabaseRepository: OrderDatabaseRepository,
|
||||
private readonly orderConverter: OrderConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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 and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one
|
||||
* per sale not yet stored as completed).
|
||||
*/
|
||||
async syncCustomers() {
|
||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||
@@ -29,21 +46,105 @@ export class CustomerHiboutikService {
|
||||
}
|
||||
|
||||
let nbFailed = 0;
|
||||
let nbFailedAddresses = 0;
|
||||
let nbFailedOrders = 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
|
||||
// retreive orders DTOs
|
||||
// save orders
|
||||
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
||||
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
||||
} catch (error) {
|
||||
nbFailed++;
|
||||
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
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, ${nbFailedOrders} orders 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
|
||||
async (dto) => {
|
||||
const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto;
|
||||
await this.customerAddressDatabaseRepository.upsert(
|
||||
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes an embedded address with its company details (one API call). On failure, the embedded address is returned
|
||||
* as is, so the address is still synced and its stored company details are kept.
|
||||
*/
|
||||
private async withCompanyDetails(
|
||||
dto: CustomerAddressHiboutikDto,
|
||||
): Promise<CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto> {
|
||||
try {
|
||||
const details = await this.customerHiboutikRepository.getAddressById(dto.address_id);
|
||||
return {
|
||||
...dto,
|
||||
tax_number: details.tax_number,
|
||||
company_number: details.company_number,
|
||||
legal_status: details.legal_status,
|
||||
updated_at: details.updated_at,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the sales of a full Hiboutik customer as orders: each sale not yet stored as completed is fetched (for its
|
||||
* lines) and upserted (a failing sale is logged and skipped). Quotations are ignored. Orders are never deleted.
|
||||
* @returns the number of failed sales.
|
||||
*/
|
||||
private async syncOrders(hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
if (!Array.isArray(hiboutikCustomer.sales)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no sales array: orders sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
||||
hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)),
|
||||
);
|
||||
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id)));
|
||||
|
||||
return forEachSafely(
|
||||
salesToSync,
|
||||
async (dto) => {
|
||||
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
||||
if (Number(sale.quotation) === 1) return;
|
||||
// Linked to the parent customer, whatever the sale says
|
||||
await this.orderDatabaseRepository.upsert(
|
||||
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik sale ${dto.sale_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user