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 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-07 10:48:54 +01:00
parent ad8e39f63c
commit 1a04bafa0b
10 changed files with 371 additions and 4 deletions

View File

@@ -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[];
};

View File

@@ -22,6 +22,9 @@ 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 { CustomerAddressConverter } from './models/converter/customerAddress.converter';
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository'; import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service'; import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
@@ -56,11 +59,14 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
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,

View 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;
});
}
}

View 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;
}
}

View 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([]);
});
});

View 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.');
}
}

View 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;
}

View 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];

View File

@@ -7,6 +7,9 @@ describe('CustomerHiboutikService', () => {
const customerConverter = { fromHiboutik: jest.fn() }; const customerConverter = { fromHiboutik: jest.fn() };
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) }; 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(() => {
@@ -22,12 +25,17 @@ describe('CustomerHiboutikService', () => {
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' }); customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
customerDatabaseRepository.deleteMissing.mockResolvedValue(0); customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
customerAddressDatabaseRepository.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, customerAddressDatabaseRepository as never,
customerAddressConverter 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); 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();
});
});
}); });

View File

@@ -10,6 +10,9 @@ import {
FullCustomerHiboutikDto, FullCustomerHiboutikDto,
} from '../../models/hiboutikDTO/customer.hiboutik.dto'; } from '../../models/hiboutikDTO/customer.hiboutik.dto';
import { forEachSafely } from '../../utils/sync'; 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 {
@@ -21,11 +24,15 @@ export class CustomerHiboutikService {
private readonly customerConverter: CustomerConverter, private readonly customerConverter: CustomerConverter,
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository, private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
private readonly customerAddressConverter: CustomerAddressConverter, 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
* 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() { async syncCustomers() {
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
@@ -40,20 +47,20 @@ export class CustomerHiboutikService {
let nbFailed = 0; let nbFailed = 0;
let nbFailedAddresses = 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);
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer); nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
// retreive orders DTOs nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
// 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, ${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; 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}`,
);
}
} }