From 9e6e5162ad66aa204e98e8f820614da730cdc491 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 7 Nov 2026 10:37:00 +0100 Subject: [PATCH 1/3] feat(sync): synchronize customer addresses Mirror the addresses embedded in GET /customer/:id: per customer, delete the ones missing from Hiboutik, then upsert each of them (failures logged and skipped). CustomerAddress gains its own recipient (firstName, lastName, company, phone) plus state and other. Co-Authored-By: Claude Opus 5.5 --- prisma/schema.prisma | 9 ++- src/app.module.ts | 4 ++ .../customerAddress.database.repository.ts | 27 ++++++++ .../customerAddress.converter.spec.ts | 59 ++++++++++++++++++ .../converter/customerAddress.converter.ts | 32 ++++++++++ .../customer.hiboutik.service.spec.ts | 61 ++++++++++++++++++- .../hiboutik/customer.hiboutik.service.ts | 44 +++++++++++-- 7 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/database/customerAddress.database.repository.ts create mode 100644 src/models/converter/customerAddress.converter.spec.ts create mode 100644 src/models/converter/customerAddress.converter.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a682abd..1decaeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app.module.ts b/src/app.module.ts index bb44aee..84bca7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/database/customerAddress.database.repository.ts b/src/database/customerAddress.database.repository.ts new file mode 100644 index 0000000..c5308fc --- /dev/null +++ b/src/database/customerAddress.database.repository.ts @@ -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 { + 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 { + const result = await this.prisma.customerAddress.deleteMany({ + where: { customerId, hiboutikId: { notIn: keptHiboutikIds } }, + }); + return result.count; + } +} diff --git a/src/models/converter/customerAddress.converter.spec.ts b/src/models/converter/customerAddress.converter.spec.ts new file mode 100644 index 0000000..75aa57b --- /dev/null +++ b/src/models/converter/customerAddress.converter.spec.ts @@ -0,0 +1,59 @@ +import { CustomerAddressConverter } from './customerAddress.converter'; +import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; + +const hiboutikAddress = (overrides: Partial = {}): 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(''); + }); +}); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts new file mode 100644 index 0000000..dcd52c9 --- /dev/null +++ b/src/models/converter/customerAddress.converter.ts @@ -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 { + 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.'); + } +} diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index b15648a..c1508b9 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -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); + }); + }); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 90c19a9..909183e 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -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 { + // 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}`, ); } } -- 2.48.1 From ad8e39f63ca94f60993ae8f3f712f1588412459a Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 7 Nov 2026 10:48:49 +0100 Subject: [PATCH 2/3] feat(sync): add company details to customer addresses Addresses with a company are completed through GET /customers_addresses/:id (tax number, company number, legal status), which the customer payload does not embed. Addresses without company get these fields set to null; a failed call keeps the stored values. Co-Authored-By: Claude Opus 5.5 --- prisma/schema.prisma | 30 +++++++------ src/hiboutik/customer.hiboutik.repository.ts | 17 +++++++- .../customerAddress.converter.spec.ts | 30 +++++++++++++ .../converter/customerAddress.converter.ts | 25 +++++++++-- .../hiboutikDTO/customer.hiboutik.dto.ts | 12 ++++++ .../customer.hiboutik.service.spec.ts | 43 ++++++++++++++++++- .../hiboutik/customer.hiboutik.service.ts | 38 +++++++++++++--- 7 files changed, 172 insertions(+), 23 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1decaeb..922e85e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,19 +151,25 @@ 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 + address String + city String + company String? + /// Company registration number (SIRET...), only for an address with a company + companyNumber String? + country String + firstName String + hiboutikId Int @unique + isDefault Boolean @default(false) + lastName String + /// Company legal status (SAS, SARL...), only for an address with a company + legalStatus String? /// Free complement (building, floor, door code...) - other String? - phone String? - state String? - zipCode String + other String? + phone String? + state String? + /// Company VAT number, only for an address with a company + taxNumber String? + zipCode String customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customerId String diff --git a/src/hiboutik/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts index 95d0dc3..50179dd 100644 --- a/src/hiboutik/customer.hiboutik.repository.ts +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { CustomerHiboutikDto, FullCustomerHiboutikDto } from '../models/hiboutikDTO/customer.hiboutik.dto'; +import { + CustomerAddressDetailsHiboutikDto, + CustomerHiboutikDto, + FullCustomerHiboutikDto, +} from '../models/hiboutikDTO/customer.hiboutik.dto'; /** Reads customers from the Hiboutik API. */ @Injectable() @@ -22,4 +26,15 @@ export class CustomerHiboutikRepository { if (!customer) throw new Error(`Hiboutik customer ${id} not found`); return customer; } + + /** + * Address with its company details (Hiboutik answers with a one-element array). + * @throws if the address does not exist. + */ + async getAddressById(id: number): Promise { + const data = await this.hiboutikClient.get('/customers_addresses/' + String(id)); + const address = data[0]; + if (!address) throw new Error(`Hiboutik address ${id} not found`); + return address; + } } diff --git a/src/models/converter/customerAddress.converter.spec.ts b/src/models/converter/customerAddress.converter.spec.ts index 75aa57b..eee113f 100644 --- a/src/models/converter/customerAddress.converter.spec.ts +++ b/src/models/converter/customerAddress.converter.spec.ts @@ -56,4 +56,34 @@ describe('CustomerAddressConverter', () => { expect(address.other).toBeNull(); expect(address.firstName).toBe(''); }); + + describe('company details', () => { + const details = { tax_number: ' FR123 ', company_number: '12345678900012', legal_status: 'SAS', updated_at: '2026-11-01 10:00:00' }; + + it('maps them when fetched for an address with a company', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details }); + expect(address.taxNumber).toBe('FR123'); + expect(address.companyNumber).toBe('12345678900012'); + expect(address.legalStatus).toBe('SAS'); + }); + + it('erases empty fetched values (null)', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details, tax_number: '' }); + expect(address.taxNumber).toBeNull(); + }); + + it('keeps the stored ones when not fetched (undefined)', () => { + const address = converter.fromHiboutik(hiboutikAddress()); + expect(address.taxNumber).toBeUndefined(); + expect(address.companyNumber).toBeUndefined(); + expect(address.legalStatus).toBeUndefined(); + }); + + it('erases them when the address has no company (null)', () => { + const address = converter.fromHiboutik({ ...hiboutikAddress({ company: '' }), ...details }); + expect(address.taxNumber).toBeNull(); + expect(address.companyNumber).toBeNull(); + expect(address.legalStatus).toBeNull(); + }); + }); }); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts index dcd52c9..ab50c37 100644 --- a/src/models/converter/customerAddress.converter.ts +++ b/src/models/converter/customerAddress.converter.ts @@ -1,27 +1,44 @@ import { Injectable } from '@nestjs/common'; import { Converter } from './converter'; import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress'; -import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; +import { CustomerAddressDetailsHiboutikDto, 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() ?? ''; +/** + * Maps an address. Company details (tax/company number, legal status) are: + * - `null` when the address has no company, + * - mapped when `dto` comes from `GET /customers_addresses/:id` ({@link CustomerAddressDetailsHiboutikDto}), + * - `undefined` (stored values kept) when the details were not fetched. + */ @Injectable() -export class CustomerAddressConverter extends Converter { - fromHiboutik(dto: CustomerAddressHiboutikDto): CustomerAddressCreateInput { +export class CustomerAddressConverter extends Converter< + CustomerAddressCreateInput, + CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto +> { + fromHiboutik(dto: CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto): CustomerAddressCreateInput { + const company = optional(dto.company); + const hasDetails = 'tax_number' in dto; + const companyDetail = (value: string | null | undefined) => (!company ? null : hasDetails ? optional(value) : undefined); + const details = dto as Partial; + return { address: required(dto.address), city: required(dto.city), - company: optional(dto.company), + company, + companyNumber: companyDetail(details.company_number), country: required(dto.country), firstName: required(dto.first_name), hiboutikId: Number(dto.address_id), isDefault: Number(dto.default) === 1, lastName: required(dto.last_name), + legalStatus: companyDetail(details.legal_status), other: optional(dto.other), phone: optional(dto.phone), state: optional(dto.state), + taxNumber: companyDetail(details.tax_number), zipCode: required(dto.zip_code), customer: { connect: { hiboutikId: Number(dto.customers_id) } }, }; diff --git a/src/models/hiboutikDTO/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts index f945d69..ccb42b6 100644 --- a/src/models/hiboutikDTO/customer.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -60,6 +60,18 @@ export interface CustomerAddressHiboutikDto { other: string; } +/** + * Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company + * details and its last update. One API call per address, so only fetched for addresses with a company. + */ +export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto { + tax_number: string; + company_number: string; + legal_status: string; + /** Local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: string; +} + /** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */ export interface CustomerSaleHiboutikDto { sale_id: number; diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index c1508b9..afb09ad 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -2,7 +2,7 @@ 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() }; @@ -95,6 +95,47 @@ describe('CustomerHiboutikService', () => { 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, diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 909183e..6e5cdf0 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -4,7 +4,11 @@ import { CustomerDatabaseRepository } from '../../database/customer.database.rep 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 { + CustomerAddressDetailsHiboutikDto, + CustomerAddressHiboutikDto, + FullCustomerHiboutikDto, +} from '../../models/hiboutikDTO/customer.hiboutik.dto'; import { forEachSafely } from '../../utils/sync'; @Injectable() @@ -72,12 +76,36 @@ export class CustomerHiboutikService { 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 }), - ), + 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 { + 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; + } + } } -- 2.48.1 From 1a04bafa0bc0e89fbc9cc57921d9bef197255320 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 7 Nov 2026 10:48:54 +0100 Subject: [PATCH 3/3] feat(sync): synchronize customer orders from Hiboutik sales For each customer sale not yet stored as completed, fetch the full sale (GET /sales/:id) and upsert it as a STORE order keyed on hiboutikSaleId. Lines are replaced on each upsert and linked to the matching product version when it still exists. Quotations are skipped, orders are never deleted. Co-Authored-By: Claude Opus 5.5 --- prisma/customObjects/prisma.custom.object.ts | 8 ++ src/app.module.ts | 6 ++ src/database/order.database.repository.ts | 61 ++++++++++++++ src/hiboutik/sale.hiboutik.repository.ts | 20 +++++ src/models/converter/order.converter.spec.ts | 80 +++++++++++++++++++ src/models/converter/order.converter.ts | 49 ++++++++++++ src/models/hiboutikDTO/sale.hiboutik.dto.ts | 40 ++++++++++ src/models/orderStatus.ts | 9 +++ .../customer.hiboutik.service.spec.ts | 55 +++++++++++++ .../hiboutik/customer.hiboutik.service.ts | 47 ++++++++++- 10 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 src/database/order.database.repository.ts create mode 100644 src/hiboutik/sale.hiboutik.repository.ts create mode 100644 src/models/converter/order.converter.spec.ts create mode 100644 src/models/converter/order.converter.ts create mode 100644 src/models/hiboutikDTO/sale.hiboutik.dto.ts create mode 100644 src/models/orderStatus.ts diff --git a/prisma/customObjects/prisma.custom.object.ts b/prisma/customObjects/prisma.custom.object.ts index 2574315..0bdb5e4 100644 --- a/prisma/customObjects/prisma.custom.object.ts +++ b/prisma/customObjects/prisma.custom.object.ts @@ -2,3 +2,11 @@ import { Prisma } from '../generated/client'; /** Product loaded with its versions (`include: { versions: true }`). */ export type ProductWithVersions = Prisma.ProductGetPayload<{ include: { versions: true } }>; + +/** + * Order to save with its lines. Lines are kept apart from the nested Prisma input so that the repository can link + * them to the current catalog (`productVersion`) and replace them on update. + */ +export type OrderWithLinesInput = Omit & { + lines: Prisma.OrderLineCreateWithoutOrderInput[]; +}; diff --git a/src/app.module.ts b/src/app.module.ts index 84bca7f..db86dc2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,9 @@ 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 { OrderConverter } from './models/converter/order.converter'; +import { OrderDatabaseRepository } from './database/order.database.repository'; +import { SaleHiboutikRepository } from './hiboutik/sale.hiboutik.repository'; import { CustomerAddressConverter } from './models/converter/customerAddress.converter'; import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository'; import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service'; @@ -56,11 +59,14 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser CustomerHiboutikService, HiboutikClient, HiboutikService, + OrderConverter, + OrderDatabaseRepository, ProductConverter, ProductDatabaseRepository, ProductHiboutikRepository, ProductHiboutikService, ProductVersionDatabaseRepository, + SaleHiboutikRepository, StockHiboutikRepository, SupplierConverter, SupplierDatabaseRepository, diff --git a/src/database/order.database.repository.ts b/src/database/order.database.repository.ts new file mode 100644 index 0000000..3222cc0 --- /dev/null +++ b/src/database/order.database.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Order } from '../../prisma/generated/client'; +import { OrderWithLinesInput } from '../../prisma/customObjects/prisma.custom.object'; +import { OrderStatus } from '../models/orderStatus'; + +/** Database access to orders. Hiboutik sales are matched by `hiboutikSaleId`. */ +@Injectable() +export class OrderDatabaseRepository { + constructor(private readonly prisma: PrismaService) {} + + /** Among `hiboutikSaleIds`, the ones already stored as completed orders. */ + async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise> { + if (hiboutikSaleIds.length === 0) return new Set(); + + const orders = await this.prisma.order.findMany({ + where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED }, + select: { hiboutikSaleId: true }, + }); + return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null)); + } + + /** + * Creates or updates the order with the same `hiboutikSaleId`, replacing all its lines (atomically). + * Each line is linked to the current product version matching its Hiboutik product and size, if it still exists. + * On update, `source` is kept: a website order sent to Hiboutik stays WEB. + */ + async upsert(order: OrderWithLinesInput): Promise { + if (order.hiboutikSaleId == null) throw new Error('Cannot upsert an order without hiboutikSaleId'); + + const { lines, source, ...orderFields } = order; + const linkedLines = await this.linkToProductVersions(lines); + return this.prisma.order.upsert({ + where: { hiboutikSaleId: order.hiboutikSaleId }, + create: { ...orderFields, source, lines: { create: linkedLines } }, + update: { + ...orderFields, + // an undefined relation is ignored by Prisma on update: explicitly unlink a customer removed from the sale + customer: orderFields.customer ?? { disconnect: true }, + lines: { deleteMany: {}, create: linkedLines }, + }, + }); + } + + private async linkToProductVersions(lines: OrderWithLinesInput['lines']): Promise { + if (lines.length === 0) return lines; + + const versions = await this.prisma.productVersion.findMany({ + where: { + OR: lines.map((line) => ({ hiboutikSizeId: line.hiboutikSizeId, product: { hiboutikId: line.hiboutikProductId } })), + }, + select: { id: true, hiboutikSizeId: true, product: { select: { hiboutikId: true } } }, + }); + const versionIds = new Map(versions.map((version) => [`${version.product.hiboutikId}:${version.hiboutikSizeId}`, version.id])); + + return lines.map((line) => { + const versionId = versionIds.get(`${line.hiboutikProductId}:${line.hiboutikSizeId}`); + return versionId ? { ...line, productVersion: { connect: { id: versionId } } } : line; + }); + } +} diff --git a/src/hiboutik/sale.hiboutik.repository.ts b/src/hiboutik/sale.hiboutik.repository.ts new file mode 100644 index 0000000..993a8c3 --- /dev/null +++ b/src/hiboutik/sale.hiboutik.repository.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto'; + +/** Reads sales from the Hiboutik API. */ +@Injectable() +export class SaleHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** + * Full sale, with its lines (Hiboutik answers with a one-element array). + * @throws if the sale does not exist. + */ + async getSaleById(id: number): Promise { + const data = await this.hiboutikClient.get('/sales/' + String(id)); + const sale = data[0]; + if (!sale) throw new Error(`Hiboutik sale ${id} not found`); + return sale; + } +} diff --git a/src/models/converter/order.converter.spec.ts b/src/models/converter/order.converter.spec.ts new file mode 100644 index 0000000..c38bfcf --- /dev/null +++ b/src/models/converter/order.converter.spec.ts @@ -0,0 +1,80 @@ +import { OrderConverter } from './order.converter'; +import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto'; + +const hiboutikLine = (overrides: Partial = {}): SaleLineHiboutikDto => ({ + line_item_id: 3, + product_id: 746, + product_size: 1, + quantity: 2, + product_model: 'KATSEYE - WILD', + product_barcode: '2430010007467', + size_name: 'Daniela', + item_unit_gross_wo_discount: '19.00', + item_unit_gross: '17.10', + ...overrides, +}); + +const hiboutikSale = (overrides: Partial = {}): SaleHiboutikDto => + ({ + sale_id: 5, + created_at: '2026-10-18 11:00:21', + completed_at: '2026-10-18 11:01:04', + customer_id: 1, + total: '34.20', + quotation: 0, + line_items: [hiboutikLine()], + ...overrides, + }) as SaleHiboutikDto; + +describe('OrderConverter', () => { + const converter = new OrderConverter(); + + it('maps a completed Hiboutik sale with its lines', () => { + expect(converter.fromHiboutik(hiboutikSale())).toEqual({ + hiboutikSaleId: 5, + orderedAt: new Date('2026-10-18T11:01:04'), + source: 'STORE', + status: 'COMPLETED', + total: '34.20', + customer: { connect: { hiboutikId: 1 } }, + lines: [ + { + barcode: '2430010007467', + hiboutikProductId: 746, + hiboutikSizeId: 1, + productName: 'KATSEYE - WILD', + quantity: 2, + unitPrice: '17.10', + versionName: 'Daniela', + }, + ], + }); + }); + + it('maps a sale not completed yet as PENDING, ordered at its creation date', () => { + const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' })); + expect(order.status).toBe('PENDING'); + expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21')); + }); + + it('throws when the sale has no valid date', () => { + expect(() => converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00', created_at: '' }))).toThrow(); + }); + + it('leaves an anonymous sale without customer', () => { + expect(converter.fromHiboutik(hiboutikSale({ customer_id: 0 })).customer).toBeUndefined(); + }); + + it('uses the product name as version name and null barcode for a product without sizes', () => { + const [line] = converter.fromHiboutik( + hiboutikSale({ line_items: [hiboutikLine({ product_size: 0, size_name: '', product_barcode: '' })] }), + ).lines; + expect(line.versionName).toBe('KATSEYE - WILD'); + expect(line.barcode).toBeNull(); + expect(line.hiboutikSizeId).toBe(0); + }); + + it('accepts a sale without lines', () => { + expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]); + }); +}); diff --git a/src/models/converter/order.converter.ts b/src/models/converter/order.converter.ts new file mode 100644 index 0000000..9d8e3af --- /dev/null +++ b/src/models/converter/order.converter.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { Converter } from './converter'; +import { OrderSource } from '../../../prisma/generated/client'; +import { OrderWithLinesInput } from '../../../prisma/customObjects/prisma.custom.object'; +import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto'; +import { parseHiboutikDate } from '../../utils/date'; +import { OrderStatus } from '../orderStatus'; + +@Injectable() +export class OrderConverter extends Converter { + /** + * Maps a Hiboutik sale to an order (source STORE) with a snapshot of its lines. + * @throws if the sale has neither a valid completion date nor a valid creation date. + */ + fromHiboutik(dto: SaleHiboutikDto): OrderWithLinesInput { + const completedAt = parseHiboutikDate(dto.completed_at); + const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at); + if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`); + + const customerId = Number(dto.customer_id); + return { + hiboutikSaleId: Number(dto.sale_id), + orderedAt, + source: OrderSource.STORE, + status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING, + total: dto.total, + // 0 for an anonymous sale + customer: customerId > 0 ? { connect: { hiboutikId: customerId } } : undefined, + lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)), + }; + } + + private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] { + return { + barcode: line.product_barcode?.trim() || null, + hiboutikProductId: Number(line.product_id), + hiboutikSizeId: Number(line.product_size), + productName: line.product_model, + quantity: Number(line.quantity), + unitPrice: line.item_unit_gross, + // same rule as ProductVersion: the product name for a product without sizes + versionName: line.size_name?.trim() || line.product_model, + }; + } + + toHiboutik(): SaleHiboutikDto { + throw new Error('Method not implemented.'); + } +} diff --git a/src/models/hiboutikDTO/sale.hiboutik.dto.ts b/src/models/hiboutikDTO/sale.hiboutik.dto.ts new file mode 100644 index 0000000..4d3486c --- /dev/null +++ b/src/models/hiboutikDTO/sale.hiboutik.dto.ts @@ -0,0 +1,40 @@ +/** + * Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. + * Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts, + * totals by tax, customer and store details...). + */ +export interface SaleHiboutikDto { + sale_id: number; + /** Local time, "YYYY-MM-DD HH:mm:ss". */ + created_at: string; + /** Local time, "0000-00-00 00:00:00" while the sale is not completed. */ + completed_at: string; + store_id: number; + customer_id: number; + currency: string; + /** Decimal as a string, taxes included. */ + total: string; + /** Payment type code, e.g. "CB", "ESP", "WEB". */ + payment: string; + /** 1 for a quotation. */ + quotation: number; + line_items: SaleLineHiboutikDto[]; +} + +/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */ +export interface SaleLineHiboutikDto { + line_item_id: number; + product_id: number; + /** Hiboutik size id, 0 for a product without sizes. */ + product_size: number; + /** Negative for a returned item. */ + quantity: number; + product_model: string; + product_barcode: string; + /** Size name, empty for a product without sizes. */ + size_name: string; + /** Unit price before discount, taxes included (decimal as a string). */ + item_unit_gross_wo_discount: string; + /** Unit price actually paid, discount deducted, taxes included (decimal as a string). */ + item_unit_gross: string; +} diff --git a/src/models/orderStatus.ts b/src/models/orderStatus.ts new file mode 100644 index 0000000..e23e101 --- /dev/null +++ b/src/models/orderStatus.ts @@ -0,0 +1,9 @@ +/** Values of `Order.status`. */ +export const OrderStatus = { + /** Hiboutik sale not completed (cart in progress, awaiting payment...). */ + PENDING: 'PENDING', + /** Hiboutik sale completed: it no longer changes, so the sync does not fetch it again. */ + COMPLETED: 'COMPLETED', +} as const; + +export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus]; diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index afb09ad..bf7115b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -7,6 +7,9 @@ describe('CustomerHiboutikService', () => { 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(() => { @@ -22,12 +25,17 @@ describe('CustomerHiboutikService', () => { 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, ); }); @@ -148,4 +156,51 @@ describe('CustomerHiboutikService', () => { 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(); + }); + }); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 6e5cdf0..996c09b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -10,6 +10,9 @@ import { 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 { @@ -21,11 +24,15 @@ export class CustomerHiboutikService { 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 - * with its addresses (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(); @@ -40,20 +47,20 @@ 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); const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer); - // retreive orders DTOs - // save orders + 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, ${nbFailedAddresses} addresses failed.`, + `Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`, ); } @@ -108,4 +115,36 @@ export class CustomerHiboutikService { 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 { + 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}`, + ); + } } -- 2.48.1