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 }`). */
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 { 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,

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

View File

@@ -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<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}`,
);
}
}