feat(sync): synchronize customer adresses and orders from Hiboutik sales #1
@@ -2,3 +2,11 @@ import { Prisma } from '../generated/client';
|
|||||||
|
|
||||||
/** Product loaded with its versions (`include: { versions: true }`). */
|
/** Product loaded with its versions (`include: { versions: true }`). */
|
||||||
export type ProductWithVersions = Prisma.ProductGetPayload<{ 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<Prisma.OrderCreateInput, 'lines'> & {
|
||||||
|
lines: Prisma.OrderLineCreateWithoutOrderInput[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -146,16 +146,29 @@ 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?
|
||||||
|
/// Company registration number (SIRET...), only for an address with a company
|
||||||
|
companyNumber String?
|
||||||
country String
|
country String
|
||||||
|
firstName String
|
||||||
hiboutikId Int @unique
|
hiboutikId Int @unique
|
||||||
isDefault Boolean @default(false)
|
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?
|
||||||
|
/// Company VAT number, only for an address with a company
|
||||||
|
taxNumber 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,11 @@ 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 { 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';
|
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,17 +51,22 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
|
|||||||
CategoryDatabaseRepository,
|
CategoryDatabaseRepository,
|
||||||
CategoryHiboutikRepository,
|
CategoryHiboutikRepository,
|
||||||
CategoryHiboutikService,
|
CategoryHiboutikService,
|
||||||
|
CustomerAddressConverter,
|
||||||
|
CustomerAddressDatabaseRepository,
|
||||||
CustomerHiboutikRepository,
|
CustomerHiboutikRepository,
|
||||||
CustomerDatabaseRepository,
|
CustomerDatabaseRepository,
|
||||||
CustomerConverter,
|
CustomerConverter,
|
||||||
CustomerHiboutikService,
|
CustomerHiboutikService,
|
||||||
HiboutikClient,
|
HiboutikClient,
|
||||||
HiboutikService,
|
HiboutikService,
|
||||||
|
OrderConverter,
|
||||||
|
OrderDatabaseRepository,
|
||||||
ProductConverter,
|
ProductConverter,
|
||||||
ProductDatabaseRepository,
|
ProductDatabaseRepository,
|
||||||
ProductHiboutikRepository,
|
ProductHiboutikRepository,
|
||||||
ProductHiboutikService,
|
ProductHiboutikService,
|
||||||
ProductVersionDatabaseRepository,
|
ProductVersionDatabaseRepository,
|
||||||
|
SaleHiboutikRepository,
|
||||||
StockHiboutikRepository,
|
StockHiboutikRepository,
|
||||||
SupplierConverter,
|
SupplierConverter,
|
||||||
SupplierDatabaseRepository,
|
SupplierDatabaseRepository,
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/database/order.database.repository.ts
Normal file
61
src/database/order.database.repository.ts
Normal file
@@ -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<Set<number>> {
|
||||||
|
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<Order> {
|
||||||
|
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<OrderWithLinesInput['lines']> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
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. */
|
/** Reads customers from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -22,4 +26,15 @@ export class CustomerHiboutikRepository {
|
|||||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||||
return customer;
|
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<CustomerAddressDetailsHiboutikDto> {
|
||||||
|
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
||||||
|
const address = data[0];
|
||||||
|
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/hiboutik/sale.hiboutik.repository.ts
Normal file
20
src/hiboutik/sale.hiboutik.repository.ts
Normal file
@@ -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<SaleHiboutikDto> {
|
||||||
|
const data = await this.hiboutikClient.get<SaleHiboutikDto[]>('/sales/' + String(id));
|
||||||
|
const sale = data[0];
|
||||||
|
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
||||||
|
return sale;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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('');
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/models/converter/customerAddress.converter.ts
Normal file
49
src/models/converter/customerAddress.converter.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Converter } from './converter';
|
||||||
|
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
||||||
|
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<
|
||||||
|
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<CustomerAddressDetailsHiboutikDto>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
address: required(dto.address),
|
||||||
|
city: required(dto.city),
|
||||||
|
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) } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
toHiboutik(): CustomerAddressHiboutikDto {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/models/converter/order.converter.spec.ts
Normal file
80
src/models/converter/order.converter.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { OrderConverter } from './order.converter';
|
||||||
|
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||||
|
|
||||||
|
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): 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> = {}): 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/models/converter/order.converter.ts
Normal file
49
src/models/converter/order.converter.ts
Normal file
@@ -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<OrderWithLinesInput, SaleHiboutikDto> {
|
||||||
|
/**
|
||||||
|
* 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,18 @@ export interface CustomerAddressHiboutikDto {
|
|||||||
other: string;
|
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`. */
|
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
|
||||||
export interface CustomerSaleHiboutikDto {
|
export interface CustomerSaleHiboutikDto {
|
||||||
sale_id: number;
|
sale_id: number;
|
||||||
|
|||||||
40
src/models/hiboutikDTO/sale.hiboutik.dto.ts
Normal file
40
src/models/hiboutikDTO/sale.hiboutik.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
9
src/models/orderStatus.ts
Normal file
9
src/models/orderStatus.ts
Normal file
@@ -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];
|
||||||
@@ -2,9 +2,14 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||||
|
|
||||||
describe('CustomerHiboutikService', () => {
|
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 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) };
|
||||||
|
const saleHiboutikRepository = { getSaleById: jest.fn() };
|
||||||
|
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
|
||||||
|
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||||
let service: CustomerHiboutikService;
|
let service: CustomerHiboutikService;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -16,12 +21,21 @@ 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);
|
||||||
|
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||||
|
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 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,
|
||||||
|
saleHiboutikRepository as never,
|
||||||
|
orderDatabaseRepository as never,
|
||||||
|
orderConverter as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,4 +62,145 @@ 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('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 { 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 {
|
||||||
|
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()
|
@Injectable()
|
||||||
export class CustomerHiboutikService {
|
export class CustomerHiboutikService {
|
||||||
@@ -11,11 +22,17 @@ 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,
|
||||||
|
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
|
* 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() {
|
async syncCustomers() {
|
||||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||||
@@ -29,21 +46,105 @@ export class CustomerHiboutikService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nbFailed = 0;
|
let nbFailed = 0;
|
||||||
|
let nbFailedAddresses = 0;
|
||||||
|
let nbFailedOrders = 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
|
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
||||||
// retreive orders DTOs
|
|
||||||
// save orders
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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, ${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