feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies #4
@@ -23,13 +23,20 @@ export class CustomerDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllHiboutikIds(): Promise<number[]> {
|
||||||
|
const customers = await this.prisma.customer.findMany({ select: { hiboutikId: true } });
|
||||||
|
return customers.map((customer) => customer.hiboutikId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the customers that no longer exist in Hiboutik.
|
* Deletes customers that no longer exist in Hiboutik.
|
||||||
* Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull).
|
* Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull).
|
||||||
*/
|
*/
|
||||||
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
async deleteByHiboutikIds(hiboutikIds: number[]): Promise<number> {
|
||||||
|
if (hiboutikIds.length === 0) return 0;
|
||||||
|
|
||||||
const result = await this.prisma.customer.deleteMany({
|
const result = await this.prisma.customer.deleteMany({
|
||||||
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
where: { hiboutikId: { in: hiboutikIds } },
|
||||||
});
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ import { OrderStatus } from '../models/orderStatus';
|
|||||||
export class OrderDatabaseRepository {
|
export class OrderDatabaseRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/** Among `hiboutikSaleIds`, the ones already stored as completed orders. */
|
/**
|
||||||
async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise<Set<number>> {
|
* Among `hiboutikSaleIds`, the ones already stored as completed orders of this customer. A completed order that lost
|
||||||
|
* its customer (customer deleted, then found again in Hiboutik) is not returned, so that the sync links it back.
|
||||||
|
*/
|
||||||
|
async getCompletedSaleIds(customerHiboutikId: number, hiboutikSaleIds: number[]): Promise<Set<number>> {
|
||||||
if (hiboutikSaleIds.length === 0) return new Set();
|
if (hiboutikSaleIds.length === 0) return new Set();
|
||||||
|
|
||||||
const orders = await this.prisma.order.findMany({
|
const orders = await this.prisma.order.findMany({
|
||||||
where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED },
|
where: {
|
||||||
|
hiboutikSaleId: { in: hiboutikSaleIds },
|
||||||
|
status: OrderStatus.COMPLETED,
|
||||||
|
customer: { is: { hiboutikId: customerHiboutikId } },
|
||||||
|
},
|
||||||
select: { hiboutikSaleId: true },
|
select: { hiboutikSaleId: true },
|
||||||
});
|
});
|
||||||
return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null));
|
return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { Product } from '../../prisma/generated/client';
|
import { Prisma, Product } from '../../prisma/generated/client';
|
||||||
import { ProductCreateInput } from '../../prisma/generated/models/Product';
|
import { ProductCreateInput } from '../../prisma/generated/models/Product';
|
||||||
|
|
||||||
/** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */
|
/** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||||
@@ -14,9 +14,12 @@ export class ProductDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. */
|
/**
|
||||||
async upsert(product: ProductCreateInput): Promise<Product> {
|
* Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked.
|
||||||
return this.prisma.product.upsert({
|
* @param db transaction client, to run it inside a transaction.
|
||||||
|
*/
|
||||||
|
async upsert(product: ProductCreateInput, db: Prisma.TransactionClient = this.prisma): Promise<Product> {
|
||||||
|
return db.product.upsert({
|
||||||
where: { hiboutikId: product.hiboutikId },
|
where: { hiboutikId: product.hiboutikId },
|
||||||
create: product,
|
create: product,
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { ProductVersion } from '../../prisma/generated/client';
|
import { Prisma, ProductVersion } from '../../prisma/generated/client';
|
||||||
import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion';
|
import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion';
|
||||||
|
|
||||||
/** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */
|
/** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */
|
||||||
@@ -14,9 +14,16 @@ export class ProductVersionDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates or updates the version of `productId` with the same `hiboutikSizeId`. */
|
/**
|
||||||
async upsert(productId: string, productVersion: ProductVersionCreateInput): Promise<ProductVersion> {
|
* Creates or updates the version of `productId` with the same `hiboutikSizeId`.
|
||||||
return this.prisma.productVersion.upsert({
|
* @param db transaction client, to run it inside a transaction.
|
||||||
|
*/
|
||||||
|
async upsert(
|
||||||
|
productId: string,
|
||||||
|
productVersion: ProductVersionCreateInput,
|
||||||
|
db: Prisma.TransactionClient = this.prisma,
|
||||||
|
): Promise<ProductVersion> {
|
||||||
|
return db.productVersion.upsert({
|
||||||
where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } },
|
where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } },
|
||||||
create: productVersion,
|
create: productVersion,
|
||||||
update: productVersion,
|
update: productVersion,
|
||||||
@@ -26,9 +33,10 @@ export class ProductVersionDatabaseRepository {
|
|||||||
/**
|
/**
|
||||||
* Deletes the versions of a product that no longer exist in Hiboutik.
|
* Deletes the versions of a product that no longer exist in Hiboutik.
|
||||||
* Order lines keep their own snapshot of the product, their link is set to null.
|
* Order lines keep their own snapshot of the product, their link is set to null.
|
||||||
|
* @param db transaction client, to run it inside a transaction.
|
||||||
*/
|
*/
|
||||||
async deleteMissing(productId: string, keptHiboutikSizeIds: number[]): Promise<number> {
|
async deleteMissing(productId: string, keptHiboutikSizeIds: number[], db: Prisma.TransactionClient = this.prisma): Promise<number> {
|
||||||
const result = await this.prisma.productVersion.deleteMany({
|
const result = await db.productVersion.deleteMany({
|
||||||
where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } },
|
where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } },
|
||||||
});
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
import { BrandHiboutikDto, brandHiboutikSchema } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads brands from the Hiboutik API. */
|
/** Reads brands from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrandHiboutikRepository {
|
export class BrandHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
|
/** Walked twice when it spans several pages: it drives the deletion of brands (see `getAllPagesForSync`). */
|
||||||
async getBrands(): Promise<BrandHiboutikDto[]> {
|
async getBrands(): Promise<BrandHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPages<BrandHiboutikDto>('/brands', {
|
return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, {
|
||||||
order_by: 'brand_id',
|
order_by: 'brand_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto';
|
import { CategoryHiboutikDto, categoryHiboutikSchema } from '../models/hiboutikDTO/category.hiboutik.dto';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
|
|
||||||
/** Reads categories from the Hiboutik API. */
|
/** Reads categories from the Hiboutik API. */
|
||||||
@@ -7,10 +7,13 @@ import { HiboutikClient } from './hiboutik.client';
|
|||||||
export class CategoryHiboutikRepository {
|
export class CategoryHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
/** Sorted by parent id, which does not guarantee that a parent comes before its children. */
|
/**
|
||||||
|
* Sorted by id (unique, so that the pages are stable), a parent may come after its children. Walked twice when it
|
||||||
|
* spans several pages: it drives the deletion of categories (see `getAllPagesForSync`).
|
||||||
|
*/
|
||||||
async getCategories(): Promise<CategoryHiboutikDto[]> {
|
async getCategories(): Promise<CategoryHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPages<CategoryHiboutikDto>('/categories', {
|
return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, {
|
||||||
order_by: 'category_id_parent',
|
order_by: 'category_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import {
|
import {
|
||||||
CustomerAddressDetailsHiboutikDto,
|
CustomerAddressDetailsHiboutikDto,
|
||||||
|
customerAddressDetailsHiboutikSchema,
|
||||||
CustomerHiboutikDto,
|
CustomerHiboutikDto,
|
||||||
|
customerHiboutikSchema,
|
||||||
FullCustomerHiboutikDto,
|
FullCustomerHiboutikDto,
|
||||||
|
fullCustomerHiboutikSchema,
|
||||||
} from '../models/hiboutikDTO/customer.hiboutik.dto';
|
} from '../models/hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads customers from the Hiboutik API. */
|
/** Reads customers from the Hiboutik API. */
|
||||||
@@ -11,29 +14,39 @@ import {
|
|||||||
export class CustomerHiboutikRepository {
|
export class CustomerHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
/** Customer summaries only: use {@link getCustomerById} for addresses and sales. */
|
/**
|
||||||
|
* Customer summaries only: use {@link getCustomerById} for addresses and sales. Sorted by id so that the pages are
|
||||||
|
* stable, but a customer created or deleted while they are walked can still shift the following ones.
|
||||||
|
*/
|
||||||
async getCustomers(): Promise<CustomerHiboutikDto[]> {
|
async getCustomers(): Promise<CustomerHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPages<CustomerHiboutikDto>('/customers');
|
return this.hiboutikClient.getAllPages('/customers', customerHiboutikSchema, {
|
||||||
|
order_by: 'customers_id',
|
||||||
|
sort: 'ASC',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full customer, with its addresses and sales (Hiboutik answers with a one-element array).
|
* Full customer, with its addresses and sales.
|
||||||
* @throws if the customer does not exist.
|
* @returns `null` when Hiboutik says the customer does not exist (404 or empty answer).
|
||||||
|
* @throws on any other failure: an unknown state is never taken for a deleted customer.
|
||||||
*/
|
*/
|
||||||
|
async findCustomerById(id: number): Promise<FullCustomerHiboutikDto | null> {
|
||||||
|
return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as {@link findCustomerById}, but a missing customer throws. */
|
||||||
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
|
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
|
||||||
const data = await this.hiboutikClient.get<FullCustomerHiboutikDto[]>('/customer/' + String(id));
|
const customer = await this.findCustomerById(id);
|
||||||
const customer = data[0];
|
|
||||||
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).
|
* Address with its company details.
|
||||||
* @throws if the address does not exist.
|
* @throws if the address does not exist.
|
||||||
*/
|
*/
|
||||||
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
||||||
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
const address = await this.hiboutikClient.getOne(`/customers_addresses/${id}`, customerAddressDetailsHiboutikSchema);
|
||||||
const address = data[0];
|
|
||||||
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
|
import { AxiosError, AxiosHeaders } from 'axios';
|
||||||
import { of, throwError } from 'rxjs';
|
import { of, throwError } from 'rxjs';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { z } from 'zod';
|
||||||
|
import { HiboutikClient, HiboutikRateLimitError } from './hiboutik.client';
|
||||||
|
|
||||||
describe('HiboutikClient', () => {
|
describe('HiboutikClient', () => {
|
||||||
const httpService = { get: jest.fn() };
|
const httpService = { get: jest.fn() };
|
||||||
const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) };
|
const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) };
|
||||||
|
const schema = z.object({ id: z.coerce.number() });
|
||||||
let client: HiboutikClient;
|
let client: HiboutikClient;
|
||||||
|
|
||||||
const answer = (data: unknown) => of({ data });
|
const answer = (data: unknown) => of({ data });
|
||||||
|
const httpError = (status: number) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new AxiosError(`Request failed with status code ${status}`, undefined, undefined, undefined, {
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: { headers: new AxiosHeaders() },
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index }));
|
const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -18,30 +32,42 @@ describe('HiboutikClient', () => {
|
|||||||
it('returns the list of a 2xx answer, empty or not', async () => {
|
it('returns the list of a 2xx answer, empty or not', async () => {
|
||||||
httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([]));
|
httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([]));
|
||||||
|
|
||||||
await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]);
|
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats a 2xx answer without body as an empty list', async () => {
|
it('treats a 2xx answer without body as an empty list', async () => {
|
||||||
httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null));
|
httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null));
|
||||||
|
|
||||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||||
await expect(client.getList('/x')).resolves.toEqual([]);
|
await expect(client.getList('/x', schema)).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a 2xx answer that is not a list, without leaking its content', async () => {
|
it('rejects a 2xx answer that is not a list, without leaking its content', async () => {
|
||||||
httpService.get.mockReturnValueOnce(answer({ error: 'secret details' }));
|
httpService.get.mockReturnValueOnce(answer({ error: 'secret details' }));
|
||||||
|
|
||||||
const result = client.getList('/x');
|
const result = client.getList('/x', schema);
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('a list was expected');
|
await expect(result).rejects.toThrow('a list was expected');
|
||||||
await expect(result).rejects.not.toThrow('secret details');
|
await expect(result).rejects.not.toThrow('secret details');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets HTTP errors through', async () => {
|
it('validates and normalizes each item', async () => {
|
||||||
httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500')));
|
httpService.get.mockReturnValueOnce(answer([{ id: '1', extra: true }]));
|
||||||
|
|
||||||
await expect(client.getList('/x')).rejects.toThrow('500');
|
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the whole list when an item is invalid, so that nothing is deleted', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(answer([{ id: 1 }, { id: 'abc' }]));
|
||||||
|
|
||||||
|
await expect(client.getList('/x', schema)).rejects.toThrow('Unexpected answer from Hiboutik /x: invalid 1.id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets HTTP errors through', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(httpError(500));
|
||||||
|
|
||||||
|
await expect(client.getList('/x', schema)).rejects.toThrow('500');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,18 +75,140 @@ describe('HiboutikClient', () => {
|
|||||||
it('walks the pages until a short one', async () => {
|
it('walks the pages until a short one', async () => {
|
||||||
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10)));
|
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10)));
|
||||||
|
|
||||||
const results = await client.getAllPages('/x');
|
const results = await client.getAllPages('/x', schema);
|
||||||
|
|
||||||
expect(results).toHaveLength(260);
|
expect(results).toHaveLength(260);
|
||||||
expect(httpService.get).toHaveBeenCalledTimes(2);
|
expect(httpService.get).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails as a whole when a later page fails, instead of returning a truncated list', async () => {
|
it('fails as a whole when a later page fails, instead of returning a truncated list', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(httpError(502));
|
||||||
|
|
||||||
|
await expect(client.getAllPages('/x', schema)).rejects.toThrow('502');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllPagesForSync', () => {
|
||||||
|
const idOf = (item: { id: number }) => item.id;
|
||||||
|
const ids = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => ({ id: from + index }));
|
||||||
|
|
||||||
|
it('walks a list that fits in one page only once', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(answer(page(10)));
|
||||||
|
|
||||||
|
await expect(client.getAllPagesForSync('/x', schema, idOf)).resolves.toHaveLength(10);
|
||||||
|
expect(httpService.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks a multi-page list twice and keeps an item skipped by a page shift in one walk', async () => {
|
||||||
|
// First walk: item 10 is deleted right after page 1 is read, so item 250 moves to page 1 and page 2 starts at 251
|
||||||
|
httpService.get.mockReturnValueOnce(answer(ids(0, 249))).mockReturnValueOnce(answer(ids(251, 260)));
|
||||||
|
// Second walk: the list without item 10
|
||||||
|
const withoutTen = ids(0, 250).filter(({ id }) => id !== 10);
|
||||||
|
httpService.get.mockReturnValueOnce(answer(withoutTen)).mockReturnValueOnce(answer(ids(251, 260)));
|
||||||
|
|
||||||
|
const results = await client.getAllPagesForSync('/x', schema, idOf);
|
||||||
|
|
||||||
|
expect(httpService.get).toHaveBeenCalledTimes(4);
|
||||||
|
// Item 250 is kept (it would otherwise be deleted); item 10 is only deleted at the next sync
|
||||||
|
expect(results.map(idOf).sort((a, b) => a - b)).toEqual(ids(0, 260).map(idOf));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails as a whole when the second walk fails', async () => {
|
||||||
httpService.get
|
httpService.get
|
||||||
.mockReturnValueOnce(answer(page(250)))
|
.mockReturnValueOnce(answer(page(250)))
|
||||||
.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502')));
|
.mockReturnValueOnce(answer(page(10)))
|
||||||
|
.mockReturnValueOnce(httpError(502));
|
||||||
|
|
||||||
await expect(client.getAllPages('/x')).rejects.toThrow('502');
|
await expect(client.getAllPagesForSync('/x', schema, idOf)).rejects.toThrow('502');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate limit (429)', () => {
|
||||||
|
let sleep: jest.SpyInstance;
|
||||||
|
const tooManyRequests = (retryAfter?: string) =>
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new AxiosError('Request failed with status code 429', undefined, undefined, undefined, {
|
||||||
|
status: 429,
|
||||||
|
statusText: '',
|
||||||
|
headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter },
|
||||||
|
config: { headers: new AxiosHeaders() },
|
||||||
|
data: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sleep = jest.spyOn(client as unknown as { sleep: (ms: number) => Promise<void> }, 'sleep').mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries with exponential backoff, then succeeds', async () => {
|
||||||
|
httpService.get
|
||||||
|
.mockReturnValueOnce(tooManyRequests())
|
||||||
|
.mockReturnValueOnce(tooManyRequests())
|
||||||
|
.mockReturnValueOnce(answer([{ id: 1 }]));
|
||||||
|
|
||||||
|
await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]);
|
||||||
|
expect(sleep.mock.calls).toEqual([[2_000], [4_000]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits as asked by Retry-After, capped at 60 s', async () => {
|
||||||
|
httpService.get
|
||||||
|
.mockReturnValueOnce(tooManyRequests('5'))
|
||||||
|
.mockReturnValueOnce(tooManyRequests('3600'))
|
||||||
|
.mockReturnValueOnce(answer([]));
|
||||||
|
|
||||||
|
await client.getList('/x', schema);
|
||||||
|
|
||||||
|
expect(sleep.mock.calls).toEqual([[5_000], [60_000]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up with a HiboutikRateLimitError after 3 retries', async () => {
|
||||||
|
httpService.get.mockReturnValue(tooManyRequests());
|
||||||
|
|
||||||
|
await expect(client.getList('/x', schema)).rejects.toThrow(HiboutikRateLimitError);
|
||||||
|
expect(httpService.get).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry other errors', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(httpError(500));
|
||||||
|
|
||||||
|
await expect(client.getList('/x', schema)).rejects.toThrow('500');
|
||||||
|
expect(sleep).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not take a rate limited single resource for a missing one', async () => {
|
||||||
|
httpService.get.mockReturnValue(tooManyRequests());
|
||||||
|
|
||||||
|
await expect(client.getOne('/x/7', schema)).rejects.toThrow(HiboutikRateLimitError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOne', () => {
|
||||||
|
it('returns the single element of the answer', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(answer([{ id: '7' }]));
|
||||||
|
|
||||||
|
await expect(client.getOne('/x/7', schema)).resolves.toEqual({ id: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when Hiboutik says the resource does not exist (404 or empty answer)', async () => {
|
||||||
|
httpService.get.mockReturnValueOnce(httpError(404)).mockReturnValueOnce(answer([])).mockReturnValueOnce(answer(''));
|
||||||
|
|
||||||
|
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||||
|
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||||
|
await expect(client.getOne('/x/7', schema)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on any other failure: an unknown state is never taken for a missing resource', async () => {
|
||||||
|
httpService.get
|
||||||
|
.mockReturnValueOnce(httpError(500))
|
||||||
|
.mockReturnValueOnce(httpError(401))
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('timeout of 30000ms exceeded')))
|
||||||
|
.mockReturnValueOnce(answer({ error: 'x' }));
|
||||||
|
|
||||||
|
await expect(client.getOne('/x/7', schema)).rejects.toThrow('500');
|
||||||
|
await expect(client.getOne('/x/7', schema)).rejects.toThrow('401');
|
||||||
|
await expect(client.getOne('/x/7', schema)).rejects.toThrow('timeout');
|
||||||
|
await expect(client.getOne('/x/7', schema)).rejects.toThrow('a list was expected');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,49 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { isAxiosError } from 'axios';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { parseHiboutik } from '../models/hiboutikDTO/hiboutik.schema';
|
||||||
|
|
||||||
/** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */
|
/** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */
|
||||||
const HIBOUTIK_PAGE_SIZE = 250;
|
const HIBOUTIK_PAGE_SIZE = 250;
|
||||||
|
|
||||||
/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */
|
/** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */
|
||||||
|
const RATE_LIMIT_RETRIES = 3;
|
||||||
|
const RATE_LIMIT_BASE_DELAY_MS = 2_000;
|
||||||
|
/** Cap of the wait asked by a `Retry-After` header: beyond, waiting holds the sync for nothing (monthly quota). */
|
||||||
|
const RATE_LIMIT_MAX_DELAY_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hiboutik still answers 429 after the retries: the rate limit (30 concurrent calls) or the monthly quota (10,000
|
||||||
|
* calls per shop) is reached. The synchronization must stop: every next call would fail the same way.
|
||||||
|
*/
|
||||||
|
export class HiboutikRateLimitError extends Error {
|
||||||
|
constructor(path: string) {
|
||||||
|
super(`Hiboutik rate limit reached on ${path} (still 429 after ${RATE_LIMIT_RETRIES} retries): monthly quota exhausted?`);
|
||||||
|
this.name = 'HiboutikRateLimitError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rethrows a {@link HiboutikRateLimitError}: for the per-item `catch` blocks of the sync, which must not swallow it. */
|
||||||
|
export function rethrowIfRateLimited(error: unknown): void {
|
||||||
|
if (error instanceof HiboutikRateLimitError) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait asked by a `Retry-After` header (seconds or HTTP date), if any. */
|
||||||
|
function retryAfterMs(header: unknown): number | undefined {
|
||||||
|
if (typeof header !== 'string' || header.trim() === '') return undefined;
|
||||||
|
const seconds = Number(header);
|
||||||
|
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
||||||
|
const date = Date.parse(header);
|
||||||
|
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment.
|
||||||
|
* Every answer is validated against a zod schema (see `hiboutik.schema.ts`): a malformed one throws, like an HTTP error.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HiboutikClient {
|
export class HiboutikClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
@@ -22,45 +59,109 @@ export class HiboutikClient {
|
|||||||
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */
|
/**
|
||||||
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
* GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with
|
||||||
|
* backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}.
|
||||||
|
*/
|
||||||
|
private async get(path: string, params?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.get<T>(`${this.baseUrl}${path}`, {
|
this.httpService.get<unknown>(`${this.baseUrl}${path}`, {
|
||||||
headers: { Authorization: this.authHeader },
|
headers: { Authorization: this.authHeader },
|
||||||
params,
|
params,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAxiosError(error) || error.response?.status !== 429) throw error;
|
||||||
|
if (attempt >= RATE_LIMIT_RETRIES) throw new HiboutikRateLimitError(path);
|
||||||
|
|
||||||
|
const delay = retryAfterMs(error.response.headers['retry-after']) ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt;
|
||||||
|
await this.sleep(Math.min(delay, RATE_LIMIT_MAX_DELAY_MS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Separate method so that tests can skip the waits. */
|
||||||
|
protected sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other
|
* GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other
|
||||||
* non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer.
|
* non-list body or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred
|
||||||
|
* from a malformed answer.
|
||||||
*/
|
*/
|
||||||
async getList<T>(path: string, params?: Record<string, unknown>): Promise<T[]> {
|
async getList<T extends z.ZodType>(path: string, itemSchema: T, params?: Record<string, unknown>): Promise<z.output<T>[]> {
|
||||||
const data = await this.get<T[] | '' | null | undefined>(path, params);
|
const data = await this.get(path, params);
|
||||||
if (data === '' || data == null) return [];
|
if (data === '' || data == null) return [];
|
||||||
if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`);
|
if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`);
|
||||||
return data;
|
return parseHiboutik(z.array(itemSchema), data, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results.
|
* Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results.
|
||||||
* Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results.
|
* Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results.
|
||||||
*/
|
*/
|
||||||
async getAllPages<T>(path: string, baseParams: Record<string, unknown> = {}): Promise<T[]> {
|
async getAllPages<T extends z.ZodType>(path: string, itemSchema: T, baseParams: Record<string, unknown> = {}): Promise<z.output<T>[]> {
|
||||||
const results: T[] = [];
|
return (await this.walkPages(path, itemSchema, baseParams)).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link getAllPages}, for a list whose content drives deletions. An item created or deleted in Hiboutik while
|
||||||
|
* the pages are walked shifts the following pages, so an item can be skipped. When the list spans several pages, it
|
||||||
|
* is walked a second time and both walks are merged by id (the second one wins): an item is missing from the result
|
||||||
|
* only if both walks missed it. A list that fits in one page costs no extra call.
|
||||||
|
*/
|
||||||
|
async getAllPagesForSync<T extends z.ZodType>(
|
||||||
|
path: string,
|
||||||
|
itemSchema: T,
|
||||||
|
idOf: (item: z.output<T>) => number,
|
||||||
|
baseParams: Record<string, unknown> = {},
|
||||||
|
): Promise<z.output<T>[]> {
|
||||||
|
const firstWalk = await this.walkPages(path, itemSchema, baseParams);
|
||||||
|
if (firstWalk.nbPages <= 1) return firstWalk.items;
|
||||||
|
|
||||||
|
const secondWalk = await this.walkPages(path, itemSchema, baseParams);
|
||||||
|
const byId = new Map<number, z.output<T>>();
|
||||||
|
for (const item of [...firstWalk.items, ...secondWalk.items]) byId.set(idOf(item), item);
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks the pages of a paginated endpoint. `nbPages` counts the pages holding results. */
|
||||||
|
private async walkPages<T extends z.ZodType>(
|
||||||
|
path: string,
|
||||||
|
itemSchema: T,
|
||||||
|
baseParams: Record<string, unknown>,
|
||||||
|
): Promise<{ items: z.output<T>[]; nbPages: number }> {
|
||||||
|
const items: z.output<T>[] = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const pageData = await this.getList<T>(path, { ...baseParams, p: page });
|
const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page });
|
||||||
if (pageData.length === 0) break;
|
if (pageData.length === 0) break;
|
||||||
results.push(...pageData);
|
items.push(...pageData);
|
||||||
// A page smaller than the page size is the last one: no need to request an empty page
|
// A page smaller than the page size is the last one: no need to request an empty page
|
||||||
if (pageData.length < HIBOUTIK_PAGE_SIZE) break;
|
if (pageData.length < HIBOUTIK_PAGE_SIZE) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET on a single-resource endpoint (Hiboutik answers with a one-element array).
|
||||||
|
* @returns `null` when Hiboutik says the resource does not exist: a 404, or a 2xx answer that is empty.
|
||||||
|
* @throws on any other HTTP error or malformed answer: an unknown state is never taken for a missing resource.
|
||||||
|
*/
|
||||||
|
async getOne<T extends z.ZodType>(path: string, schema: T): Promise<z.output<T> | null> {
|
||||||
|
try {
|
||||||
|
const [item] = await this.getList(path, schema);
|
||||||
|
return item ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto';
|
import { ProductHiboutikDto, productHiboutikSchema } from '../models/hiboutikDTO/product.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads products from the Hiboutik API. */
|
/** Reads products from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductHiboutikRepository {
|
export class ProductHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
/** All products, archived ones included, with their sizes and per-size rules. */
|
/**
|
||||||
|
* All products, archived ones included, with their sizes and per-size rules. Walked twice when it spans several
|
||||||
|
* pages: it drives the deletion of products (see `getAllPagesForSync`).
|
||||||
|
*/
|
||||||
async getProducts(): Promise<ProductHiboutikDto[]> {
|
async getProducts(): Promise<ProductHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPages<ProductHiboutikDto>('/products', {
|
return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, {
|
||||||
order_by: 'product_id',
|
order_by: 'product_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
import { SaleHiboutikDto, saleHiboutikSchema } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads sales from the Hiboutik API. */
|
/** Reads sales from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -8,12 +8,11 @@ export class SaleHiboutikRepository {
|
|||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full sale, with its lines (Hiboutik answers with a one-element array).
|
* Full sale, with its lines.
|
||||||
* @throws if the sale does not exist.
|
* @throws if the sale does not exist.
|
||||||
*/
|
*/
|
||||||
async getSaleById(id: number): Promise<SaleHiboutikDto> {
|
async getSaleById(id: number): Promise<SaleHiboutikDto> {
|
||||||
const data = await this.hiboutikClient.get<SaleHiboutikDto[]>('/sales/' + String(id));
|
const sale = await this.hiboutikClient.getOne(`/sales/${id}`, saleHiboutikSchema);
|
||||||
const sale = data[0];
|
|
||||||
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
||||||
return sale;
|
return sale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
import { StockHiboutikDto, stockHiboutikSchema } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads available stocks from the Hiboutik API. */
|
/** Reads available stocks from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,12 +9,6 @@ export class StockHiboutikRepository {
|
|||||||
|
|
||||||
/** Stock of every product and size, all warehouses combined. Not paginated. */
|
/** Stock of every product and size, all warehouses combined. Not paginated. */
|
||||||
async getStocks(): Promise<StockHiboutikDto[]> {
|
async getStocks(): Promise<StockHiboutikDto[]> {
|
||||||
const stocks = await this.hiboutikClient.getList<StockHiboutikDto>('/stock_available/all_wh', {});
|
return this.hiboutikClient.getList('/stock_available/all_wh', stockHiboutikSchema);
|
||||||
// Hiboutik may return ids as strings: normalize them so strict comparisons work downstream
|
|
||||||
return stocks.map((stock) => ({
|
|
||||||
...stock,
|
|
||||||
product_id: Number(stock.product_id),
|
|
||||||
product_size: Number(stock.product_size),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
import { SupplierHiboutikDto, supplierHiboutikSchema } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads suppliers from the Hiboutik API. */
|
/** Reads suppliers from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,7 +9,7 @@ export class SupplierHiboutikRepository {
|
|||||||
|
|
||||||
/** `/suppliers` is not paginated: everything comes in one call. */
|
/** `/suppliers` is not paginated: everything comes in one call. */
|
||||||
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
|
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getList<SupplierHiboutikDto>('/suppliers', {
|
return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, {
|
||||||
order_by: 'supplier_id',
|
order_by: 'supplier_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto
|
|||||||
return {
|
return {
|
||||||
hiboutikId: dto.brand_id,
|
hiboutikId: dto.brand_id,
|
||||||
name: dto.brand_name,
|
name: dto.brand_name,
|
||||||
enabled: dto.brand_enabled == 1,
|
enabled: dto.brand_enabled === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHi
|
|||||||
return {
|
return {
|
||||||
hiboutikId: hiboutikDto.category_id,
|
hiboutikId: hiboutikDto.category_id,
|
||||||
name: hiboutikDto.category_name,
|
name: hiboutikDto.category_name,
|
||||||
enabled: hiboutikDto.category_enabled == 1,
|
enabled: hiboutikDto.category_enabled === 1,
|
||||||
position: Number(hiboutikDto.category_position) || 0,
|
position: hiboutikDto.category_position,
|
||||||
webEnabled: hiboutikDto.category_enabled_www == 1,
|
webEnabled: hiboutikDto.category_enabled_www === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { CustomerConverter } from './customer.converter';
|
import { CustomerConverter } from './customer.converter';
|
||||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto =>
|
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto => ({
|
||||||
({
|
|
||||||
customers_id: 42,
|
customers_id: 42,
|
||||||
first_name: 'Jane',
|
first_name: 'Jane',
|
||||||
last_name: 'Doe',
|
last_name: 'Doe',
|
||||||
@@ -11,7 +10,7 @@ const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): Ful
|
|||||||
date_of_birth: '1990-05-12',
|
date_of_birth: '1990-05-12',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
updated_at: '2026-06-28 17:02:55',
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as FullCustomerHiboutikDto;
|
});
|
||||||
|
|
||||||
describe('CustomerConverter', () => {
|
describe('CustomerConverter', () => {
|
||||||
const converter = new CustomerConverter();
|
const converter = new CustomerConverter();
|
||||||
@@ -22,7 +21,8 @@ describe('CustomerConverter', () => {
|
|||||||
email: 'jane@example.com',
|
email: 'jane@example.com',
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
hiboutikId: 42,
|
hiboutikId: 42,
|
||||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
// French summer time (UTC+2)
|
||||||
|
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
phone: '0600000000',
|
phone: '0600000000',
|
||||||
});
|
});
|
||||||
@@ -32,13 +32,13 @@ describe('CustomerConverter', () => {
|
|||||||
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the stored email when Hiboutik has none (undefined)', () => {
|
it('erases the email when Hiboutik has none (null)', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined();
|
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeNull();
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined();
|
expect(converter.fromHiboutik(hiboutikCustomer({ email: ' ' })).email).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erases the phone when Hiboutik has none (null)', () => {
|
it('erases the phone when Hiboutik has none (null)', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ phone: ' ' })).phone).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export class CustomerConverter extends Converter<CustomerCreateInput, FullCustom
|
|||||||
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
||||||
return {
|
return {
|
||||||
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
||||||
// undefined (not null) on purpose: an email removed in Hiboutik must not erase the one stored in database
|
// null (not undefined) so that an email removed in Hiboutik is also removed in database (data minimization)
|
||||||
email: dto.email?.trim() || undefined,
|
email: dto.email.trim() || null,
|
||||||
firstName: dto.first_name,
|
firstName: dto.first_name,
|
||||||
hiboutikId: dto.customers_id,
|
hiboutikId: dto.customers_id,
|
||||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||||
lastName: dto.last_name,
|
lastName: dto.last_name,
|
||||||
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
||||||
phone: dto.phone?.trim() || null,
|
phone: dto.phone.trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
toHiboutik(): FullCustomerHiboutikDto {
|
toHiboutik(): FullCustomerHiboutikDto {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { CustomerAddressConverter } from './customerAddress.converter';
|
import { CustomerAddressConverter } from './customerAddress.converter';
|
||||||
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto =>
|
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto => ({
|
||||||
({
|
|
||||||
address_id: 7,
|
address_id: 7,
|
||||||
customers_id: 42,
|
customers_id: 42,
|
||||||
first_name: 'Jane',
|
first_name: 'Jane',
|
||||||
@@ -17,7 +16,7 @@ const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): C
|
|||||||
other: 'Bât. B',
|
other: 'Bât. B',
|
||||||
default: 1,
|
default: 1,
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as CustomerAddressHiboutikDto;
|
});
|
||||||
|
|
||||||
describe('CustomerAddressConverter', () => {
|
describe('CustomerAddressConverter', () => {
|
||||||
const converter = new CustomerAddressConverter();
|
const converter = new CustomerAddressConverter();
|
||||||
@@ -40,17 +39,8 @@ describe('CustomerAddressConverter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 ""', () => {
|
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
|
||||||
const address = converter.fromHiboutik(
|
const address = converter.fromHiboutik(hiboutikAddress({ company: ' ', phone: '', other: '', first_name: '' }));
|
||||||
hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }),
|
|
||||||
);
|
|
||||||
expect(address.company).toBeNull();
|
expect(address.company).toBeNull();
|
||||||
expect(address.phone).toBeNull();
|
expect(address.phone).toBeNull();
|
||||||
expect(address.other).toBeNull();
|
expect(address.other).toBeNull();
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export class CustomerAddressConverter extends Converter<
|
|||||||
companyNumber: companyDetail(details.company_number),
|
companyNumber: companyDetail(details.company_number),
|
||||||
country: required(dto.country),
|
country: required(dto.country),
|
||||||
firstName: required(dto.first_name),
|
firstName: required(dto.first_name),
|
||||||
hiboutikId: Number(dto.address_id),
|
hiboutikId: dto.address_id,
|
||||||
isDefault: Number(dto.default) === 1,
|
isDefault: dto.default === 1,
|
||||||
lastName: required(dto.last_name),
|
lastName: required(dto.last_name),
|
||||||
legalStatus: companyDetail(details.legal_status),
|
legalStatus: companyDetail(details.legal_status),
|
||||||
other: optional(dto.other),
|
other: optional(dto.other),
|
||||||
@@ -40,7 +40,7 @@ export class CustomerAddressConverter extends Converter<
|
|||||||
state: optional(dto.state),
|
state: optional(dto.state),
|
||||||
taxNumber: companyDetail(details.tax_number),
|
taxNumber: companyDetail(details.tax_number),
|
||||||
zipCode: required(dto.zip_code),
|
zipCode: required(dto.zip_code),
|
||||||
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
customer: { connect: { hiboutikId: dto.customers_id } },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
toHiboutik(): CustomerAddressHiboutikDto {
|
toHiboutik(): CustomerAddressHiboutikDto {
|
||||||
|
|||||||
@@ -2,20 +2,17 @@ import { OrderConverter } from './order.converter';
|
|||||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
|
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
|
||||||
line_item_id: 3,
|
|
||||||
product_id: 746,
|
product_id: 746,
|
||||||
product_size: 1,
|
product_size: 1,
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
product_model: 'KATSEYE - WILD',
|
product_model: 'KATSEYE - WILD',
|
||||||
product_barcode: '2430010007467',
|
product_barcode: '2430010007467',
|
||||||
size_name: 'Daniela',
|
size_name: 'Daniela',
|
||||||
item_unit_gross_wo_discount: '19.00',
|
|
||||||
item_unit_gross: '17.10',
|
item_unit_gross: '17.10',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto =>
|
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto => ({
|
||||||
({
|
|
||||||
sale_id: 5,
|
sale_id: 5,
|
||||||
created_at: '2026-10-18 11:00:21',
|
created_at: '2026-10-18 11:00:21',
|
||||||
completed_at: '2026-10-18 11:01:04',
|
completed_at: '2026-10-18 11:01:04',
|
||||||
@@ -24,7 +21,7 @@ const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto
|
|||||||
quotation: 0,
|
quotation: 0,
|
||||||
line_items: [hiboutikLine()],
|
line_items: [hiboutikLine()],
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as SaleHiboutikDto;
|
});
|
||||||
|
|
||||||
describe('OrderConverter', () => {
|
describe('OrderConverter', () => {
|
||||||
const converter = new OrderConverter();
|
const converter = new OrderConverter();
|
||||||
@@ -32,7 +29,8 @@ describe('OrderConverter', () => {
|
|||||||
it('maps a completed Hiboutik sale with its lines', () => {
|
it('maps a completed Hiboutik sale with its lines', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
|
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
|
||||||
hiboutikSaleId: 5,
|
hiboutikSaleId: 5,
|
||||||
orderedAt: new Date('2026-10-18T11:01:04'),
|
// French summer time (UTC+2)
|
||||||
|
orderedAt: new Date('2026-10-18T09:01:04Z'),
|
||||||
source: 'STORE',
|
source: 'STORE',
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
total: '34.20',
|
total: '34.20',
|
||||||
@@ -54,7 +52,7 @@ describe('OrderConverter', () => {
|
|||||||
it('maps a sale not completed yet as PENDING, ordered at its creation date', () => {
|
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' }));
|
const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' }));
|
||||||
expect(order.status).toBe('PENDING');
|
expect(order.status).toBe('PENDING');
|
||||||
expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21'));
|
expect(order.orderedAt).toEqual(new Date('2026-10-18T09:00:21Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the sale has no valid date', () => {
|
it('throws when the sale has no valid date', () => {
|
||||||
@@ -75,6 +73,6 @@ describe('OrderConverter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a sale without lines', () => {
|
it('accepts a sale without lines', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]);
|
expect(converter.fromHiboutik(hiboutikSale({ line_items: [] })).lines).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,29 +17,28 @@ export class OrderConverter extends Converter<OrderWithLinesInput, SaleHiboutikD
|
|||||||
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
|
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
|
||||||
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
|
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
|
||||||
|
|
||||||
const customerId = Number(dto.customer_id);
|
|
||||||
return {
|
return {
|
||||||
hiboutikSaleId: Number(dto.sale_id),
|
hiboutikSaleId: dto.sale_id,
|
||||||
orderedAt,
|
orderedAt,
|
||||||
source: OrderSource.STORE,
|
source: OrderSource.STORE,
|
||||||
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
|
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
|
||||||
total: dto.total,
|
total: dto.total,
|
||||||
// 0 for an anonymous sale
|
// 0 for an anonymous sale
|
||||||
customer: customerId > 0 ? { connect: { hiboutikId: customerId } } : undefined,
|
customer: dto.customer_id > 0 ? { connect: { hiboutikId: dto.customer_id } } : undefined,
|
||||||
lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)),
|
lines: dto.line_items.map((line) => this.lineFromHiboutik(line)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] {
|
private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] {
|
||||||
return {
|
return {
|
||||||
barcode: line.product_barcode?.trim() || null,
|
barcode: line.product_barcode.trim() || null,
|
||||||
hiboutikProductId: Number(line.product_id),
|
hiboutikProductId: line.product_id,
|
||||||
hiboutikSizeId: Number(line.product_size),
|
hiboutikSizeId: line.product_size,
|
||||||
productName: line.product_model,
|
productName: line.product_model,
|
||||||
quantity: Number(line.quantity),
|
quantity: line.quantity,
|
||||||
unitPrice: line.item_unit_gross,
|
unitPrice: line.item_unit_gross,
|
||||||
// same rule as ProductVersion: the product name for a product without sizes
|
// same rule as ProductVersion: the product name for a product without sizes
|
||||||
versionName: line.size_name?.trim() || line.product_model,
|
versionName: line.size_name.trim() || line.product_model,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHi
|
|||||||
product_supplier: 2,
|
product_supplier: 2,
|
||||||
product_price: '13.99',
|
product_price: '13.99',
|
||||||
product_discount_price: '0.00',
|
product_discount_price: '0.00',
|
||||||
product_supply_price: '7.49',
|
|
||||||
product_category: 19,
|
product_category: 19,
|
||||||
product_display_www: 0,
|
product_display_www: 0,
|
||||||
product_arch: 0,
|
product_arch: 0,
|
||||||
products_desc: '',
|
products_desc: '',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
updated_at: '2026-06-28 17:02:55',
|
||||||
product_specific_rules: [],
|
product_specific_rules: [],
|
||||||
|
product_size_details: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ describe('ProductConverter', () => {
|
|||||||
description: '',
|
description: '',
|
||||||
discountPrice: null,
|
discountPrice: null,
|
||||||
hiboutikId: 1,
|
hiboutikId: 1,
|
||||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
|
||||||
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||||
price: '13.99',
|
price: '13.99',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
|
|||||||
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
||||||
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
||||||
return {
|
return {
|
||||||
archived: dto.product_arch == 1,
|
archived: dto.product_arch === 1,
|
||||||
barcode: dto.product_barcode || null,
|
barcode: dto.product_barcode || null,
|
||||||
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
||||||
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
||||||
@@ -20,7 +20,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
|
|||||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||||
name: dto.product_model,
|
name: dto.product_model,
|
||||||
price: dto.product_price,
|
price: dto.product_price,
|
||||||
enabled: dto.product_display_www == 1,
|
enabled: dto.product_display_www === 1,
|
||||||
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHi
|
|||||||
return {
|
return {
|
||||||
hiboutikId: dto.supplier_id,
|
hiboutikId: dto.supplier_id,
|
||||||
name: dto.supplier_name,
|
name: dto.supplier_name,
|
||||||
enabled: dto.supplier_enabled == 1,
|
enabled: dto.supplier_enabled === 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/** Brand as returned by Hiboutik `GET /brands`. */
|
import { z } from 'zod';
|
||||||
export interface BrandHiboutikDto {
|
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
||||||
brand_id: number;
|
|
||||||
brand_name: string;
|
/** Brand as returned by Hiboutik `GET /brands` (only the fields used by the sync). */
|
||||||
|
export const brandHiboutikSchema = z.object({
|
||||||
|
brand_id: hiboutikId,
|
||||||
|
brand_name: hiboutikText,
|
||||||
/** 1 = enabled, 0 = disabled. */
|
/** 1 = enabled, 0 = disabled. */
|
||||||
brand_enabled: number;
|
brand_enabled: hiboutikInt,
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export type BrandHiboutikDto = z.output<typeof brandHiboutikSchema>;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
/** Category as returned by Hiboutik `GET /categories`. */
|
import { z } from 'zod';
|
||||||
export interface CategoryHiboutikDto {
|
import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||||
category_id: number;
|
|
||||||
category_name: string;
|
/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */
|
||||||
|
export const categoryHiboutikSchema = z.object({
|
||||||
|
category_id: hiboutikId,
|
||||||
|
category_name: hiboutikText,
|
||||||
/** Id of the parent category, 0 for a root category. */
|
/** Id of the parent category, 0 for a root category. */
|
||||||
category_id_parent: number;
|
category_id_parent: hiboutikRef,
|
||||||
/** Enabled in the POS: 1 = yes, 0 = no. */
|
/** Enabled in the POS: 1 = yes, 0 = no. */
|
||||||
category_enabled: number;
|
category_enabled: hiboutikInt,
|
||||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||||
category_enabled_www: number;
|
category_enabled_www: hiboutikInt,
|
||||||
/** Display order among its siblings. */
|
/** Display order among its siblings. */
|
||||||
category_position: number;
|
category_position: hiboutikInt,
|
||||||
category_bck_color: string;
|
});
|
||||||
category_color: string;
|
|
||||||
category_desc: string;
|
export type CategoryHiboutikDto = z.output<typeof categoryHiboutikSchema>;
|
||||||
accounting_account: string;
|
|
||||||
category_ref_ext: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,91 +1,65 @@
|
|||||||
/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */
|
import { z } from 'zod';
|
||||||
export interface CustomerHiboutikDto {
|
import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||||
customers_id: number;
|
|
||||||
last_name: string;
|
|
||||||
first_name: string;
|
|
||||||
company: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
vat: string;
|
|
||||||
country: string;
|
|
||||||
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
|
|
||||||
date_of_birth: string;
|
|
||||||
customers_code: string;
|
|
||||||
loyalty_points: number;
|
|
||||||
intial_loyalty_points: number;
|
|
||||||
validity: string;
|
|
||||||
prepaid_purchases: string;
|
|
||||||
store_credit: string;
|
|
||||||
turnover: string;
|
|
||||||
orders: number;
|
|
||||||
customer_ref_ext: string;
|
|
||||||
last_order_date: string;
|
|
||||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
|
/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */
|
||||||
export interface FullCustomerHiboutikDto extends CustomerHiboutikDto {
|
export const customerHiboutikSchema = z.object({
|
||||||
gender: number;
|
customers_id: hiboutikId,
|
||||||
comments: string;
|
});
|
||||||
customers_id_referrer: number;
|
|
||||||
customers_misc: string;
|
|
||||||
accounting_account: string;
|
|
||||||
addresses: CustomerAddressHiboutikDto[];
|
|
||||||
sales: CustomerSaleHiboutikDto[];
|
|
||||||
tags: unknown[];
|
|
||||||
customers_referee: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */
|
/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */
|
||||||
export interface CustomerAddressHiboutikDto {
|
export const customerAddressHiboutikSchema = z.object({
|
||||||
address_id: number;
|
address_id: hiboutikId,
|
||||||
gender: string;
|
first_name: hiboutikText,
|
||||||
first_name: string;
|
last_name: hiboutikText,
|
||||||
last_name: string;
|
phone: hiboutikText,
|
||||||
creation_date: string;
|
company: hiboutikText,
|
||||||
email: string;
|
address: hiboutikText,
|
||||||
phone: string;
|
zip_code: hiboutikText,
|
||||||
company: string;
|
city: hiboutikText,
|
||||||
address: string;
|
state: hiboutikText,
|
||||||
zip_code: string;
|
country: hiboutikText,
|
||||||
city: string;
|
customers_id: hiboutikRef,
|
||||||
state: string;
|
|
||||||
country: string;
|
|
||||||
customers_id: number;
|
|
||||||
display: number;
|
|
||||||
editable: number;
|
|
||||||
/** 1 for the customer's default address. */
|
/** 1 for the customer's default address. */
|
||||||
default: number;
|
default: hiboutikInt,
|
||||||
other: string;
|
other: hiboutikText,
|
||||||
}
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company
|
* 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.
|
* details. One API call per address, so only fetched for addresses with a company.
|
||||||
*/
|
*/
|
||||||
export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto {
|
export const customerAddressDetailsHiboutikSchema = customerAddressHiboutikSchema.extend({
|
||||||
tax_number: string;
|
tax_number: hiboutikText,
|
||||||
company_number: string;
|
company_number: hiboutikText,
|
||||||
legal_status: string;
|
legal_status: hiboutikText,
|
||||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
||||||
updated_at: string;
|
updated_at: hiboutikText,
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
|
/** Sale header embedded in the full customer: no sale lines, they come from `GET /sales/:sale_id`. */
|
||||||
export interface CustomerSaleHiboutikDto {
|
export const customerSaleHiboutikSchema = z.object({
|
||||||
sale_id: number;
|
sale_id: hiboutikId,
|
||||||
created_at: string;
|
});
|
||||||
completed_at: string;
|
|
||||||
date_z: number;
|
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
|
||||||
store_id: number;
|
export const fullCustomerHiboutikSchema = z.object({
|
||||||
vendor_id: number;
|
customers_id: hiboutikId,
|
||||||
unique_sale_id: string;
|
last_name: hiboutikText,
|
||||||
customer_id: number;
|
first_name: hiboutikText,
|
||||||
currency: string;
|
email: hiboutikText,
|
||||||
/** Decimal as a string. */
|
phone: hiboutikText,
|
||||||
total: string;
|
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
|
||||||
points: number;
|
date_of_birth: hiboutikText,
|
||||||
billing_address: number;
|
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
||||||
shipping_address: number;
|
updated_at: hiboutikText,
|
||||||
}
|
/** `undefined` when not an array: the stored addresses are then kept. */
|
||||||
|
addresses: hiboutikOptionalList(customerAddressHiboutikSchema),
|
||||||
|
/** `undefined` when not an array: the orders sync is then skipped. */
|
||||||
|
sales: hiboutikOptionalList(customerSaleHiboutikSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CustomerHiboutikDto = z.output<typeof customerHiboutikSchema>;
|
||||||
|
export type CustomerAddressHiboutikDto = z.output<typeof customerAddressHiboutikSchema>;
|
||||||
|
export type CustomerAddressDetailsHiboutikDto = z.output<typeof customerAddressDetailsHiboutikSchema>;
|
||||||
|
export type CustomerSaleHiboutikDto = z.output<typeof customerSaleHiboutikSchema>;
|
||||||
|
export type FullCustomerHiboutikDto = z.output<typeof fullCustomerHiboutikSchema>;
|
||||||
|
|||||||
87
src/models/hiboutikDTO/hiboutik.schema.spec.ts
Normal file
87
src/models/hiboutikDTO/hiboutik.schema.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { parseHiboutik } from './hiboutik.schema';
|
||||||
|
import { customerAddressHiboutikSchema, fullCustomerHiboutikSchema } from './customer.hiboutik.dto';
|
||||||
|
import { productHiboutikSchema } from './product.hiboutik.dto';
|
||||||
|
import { saleHiboutikSchema } from './sale.hiboutik.dto';
|
||||||
|
import { stockHiboutikSchema } from './stock.hiboutik.dto';
|
||||||
|
|
||||||
|
const parse = <T extends Parameters<typeof parseHiboutik>[0]>(schema: T, data: unknown) => parseHiboutik(schema, data, '/x');
|
||||||
|
|
||||||
|
describe('Hiboutik schemas', () => {
|
||||||
|
const product = {
|
||||||
|
product_id: 1,
|
||||||
|
product_model: 'BT21 bottle',
|
||||||
|
product_barcode: '8435497280048',
|
||||||
|
product_brand: 1,
|
||||||
|
product_supplier: 2,
|
||||||
|
product_price: '13.99',
|
||||||
|
product_discount_price: '0.00',
|
||||||
|
product_category: 19,
|
||||||
|
product_display_www: 1,
|
||||||
|
product_arch: 0,
|
||||||
|
products_desc: '',
|
||||||
|
updated_at: '2026-06-28 17:02:55',
|
||||||
|
product_specific_rules: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts ids, references and flags sent as strings', () => {
|
||||||
|
const parsed = parse(customerAddressHiboutikSchema, { address_id: ' 8 ', customers_id: '43', default: '0' });
|
||||||
|
expect(parsed.address_id).toBe(8);
|
||||||
|
expect(parsed.customers_id).toBe(43);
|
||||||
|
expect(parsed.default).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns empty references into 0 and empty text into ""', () => {
|
||||||
|
const parsed = parse(productHiboutikSchema, { ...product, product_brand: '', product_category: null, products_desc: null });
|
||||||
|
expect(parsed.product_brand).toBe(0);
|
||||||
|
expect(parsed.product_category).toBe(0);
|
||||||
|
expect(parsed.products_desc).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps numeric text (zip code) and prices as strings', () => {
|
||||||
|
expect(parse(customerAddressHiboutikSchema, { address_id: 1, zip_code: 75002 }).zip_code).toBe('75002');
|
||||||
|
expect(parse(productHiboutikSchema, { ...product, product_price: 13.5 }).product_price).toBe('13.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults missing lists to [] and strips unknown fields', () => {
|
||||||
|
const parsed = parse(productHiboutikSchema, { ...product, product_specific_rules: '', product_supply_price: '7.49' });
|
||||||
|
expect(parsed.product_specific_rules).toEqual([]);
|
||||||
|
expect(parsed.product_size_details).toEqual([]);
|
||||||
|
expect(parsed).not.toHaveProperty('product_supply_price');
|
||||||
|
expect(parse(saleHiboutikSchema, { sale_id: 5, total: '0.00' }).line_items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves addresses and sales undefined when they are not arrays, so that stored data is kept', () => {
|
||||||
|
const parsed = parse(fullCustomerHiboutikSchema, { customers_id: 1, addresses: '', sales: { error: 'x' } });
|
||||||
|
expect(parsed.addresses).toBeUndefined();
|
||||||
|
expect(parsed.sales).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses stocks sent as decimal strings', () => {
|
||||||
|
expect(parse(stockHiboutikSchema, { product_id: '1', product_size: '0', stock_available: '3.000' })).toEqual({
|
||||||
|
product_id: 1,
|
||||||
|
product_size: 0,
|
||||||
|
stock_available: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a missing id', { ...product, product_id: undefined }],
|
||||||
|
['a zero id', { ...product, product_id: 0 }],
|
||||||
|
['a non-numeric id', { ...product, product_id: 'abc' }],
|
||||||
|
['a negative reference', { ...product, product_brand: -1 }],
|
||||||
|
['an invalid price', { ...product, product_price: 'free' }],
|
||||||
|
['a list that is not an array', { ...product, product_size_details: { size_id: 1 } }],
|
||||||
|
])('rejects %s', (_, data) => {
|
||||||
|
expect(() => parse(productHiboutikSchema, data)).toThrow('Unexpected answer from Hiboutik /x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the faulty fields, never their values', () => {
|
||||||
|
expect.assertions(2);
|
||||||
|
try {
|
||||||
|
parse(fullCustomerHiboutikSchema, { customers_id: 'jane@example.com', addresses: [{ address_id: 'Jane Doe' }] });
|
||||||
|
} catch (error) {
|
||||||
|
expect((error as Error).message).toMatch(/customers_id.*addresses\.0\.address_id/);
|
||||||
|
expect((error as Error).message).not.toMatch(/jane|Doe/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
58
src/models/hiboutikDTO/hiboutik.schema.ts
Normal file
58
src/models/hiboutikDTO/hiboutik.schema.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field types shared by the Hiboutik payload schemas. Hiboutik sends numbers either as JSON numbers or as numeric
|
||||||
|
* strings, and empty values as "", null or a missing key: the schemas normalize all that, so that the rest of the
|
||||||
|
* code gets real numbers and strings. Strict on what the sync relies on (identifiers, relations, prices),
|
||||||
|
* lenient (empty string) on free text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const trimmed = (value: unknown) => (typeof value === 'string' ? value.trim() : value);
|
||||||
|
const emptyAsZero = (value: unknown) => {
|
||||||
|
const v = trimmed(value);
|
||||||
|
return v == null || v === '' ? 0 : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Identifier of a Hiboutik entity (join key): a positive integer. */
|
||||||
|
export const hiboutikId = z.preprocess(trimmed, z.coerce.number().int().positive());
|
||||||
|
|
||||||
|
/** Reference to another entity: its id, or 0 (also sent as "" or null) when none. */
|
||||||
|
export const hiboutikRef = z.preprocess(emptyAsZero, z.coerce.number().int().nonnegative());
|
||||||
|
|
||||||
|
/** Integer, e.g. a 1/0 flag, a position or a quantity (0 when empty). */
|
||||||
|
export const hiboutikInt = z.preprocess(emptyAsZero, z.coerce.number().int());
|
||||||
|
|
||||||
|
/** Number, e.g. a stock that may be sent as "3.000" (0 when empty). */
|
||||||
|
export const hiboutikNumber = z.preprocess(emptyAsZero, z.coerce.number());
|
||||||
|
|
||||||
|
/** Free text, "" when empty. */
|
||||||
|
export const hiboutikText = z.preprocess((value) => (value == null ? '' : typeof value === 'number' ? String(value) : value), z.string());
|
||||||
|
|
||||||
|
/** Required decimal kept as a string for Prisma `Decimal`, e.g. "13.99". */
|
||||||
|
export const hiboutikDecimal = z.preprocess(
|
||||||
|
(value) => (typeof value === 'number' ? String(value) : trimmed(value)),
|
||||||
|
z.string().regex(/^-?\d+(\.\d+)?$/, 'decimal expected'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** List that is empty when missing ("", null or no key). */
|
||||||
|
export const hiboutikList = <T extends z.ZodType>(item: T) =>
|
||||||
|
z.preprocess((value) => (value == null || value === '' ? [] : value), z.array(item));
|
||||||
|
|
||||||
|
/** List that is `undefined` when it is not an array: the caller then keeps the stored data instead of emptying it. */
|
||||||
|
export const hiboutikOptionalList = <T extends z.ZodType>(item: T) =>
|
||||||
|
z.preprocess((value) => (Array.isArray(value) ? value : undefined), z.array(item).optional());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Hiboutik payload. The error names the faulty fields (path and issue code) but never their values,
|
||||||
|
* which may be personal data.
|
||||||
|
*/
|
||||||
|
export function parseHiboutik<T extends z.ZodType>(schema: T, data: unknown, path: string): z.output<T> {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (result.success) return result.data;
|
||||||
|
|
||||||
|
const issues = result.error.issues
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((issue) => `${issue.path.join('.') || '(root)'} (${issue.code})`)
|
||||||
|
.join(', ');
|
||||||
|
throw new Error(`Unexpected answer from Hiboutik ${path}: invalid ${issues}`);
|
||||||
|
}
|
||||||
@@ -1,46 +1,51 @@
|
|||||||
/** Product as returned by Hiboutik `GET /products` (archived products included). */
|
import { z } from 'zod';
|
||||||
export interface ProductHiboutikDto {
|
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||||
product_id: number;
|
|
||||||
/** Product name. */
|
|
||||||
product_model: string;
|
|
||||||
/** Empty string when the product has no barcode. */
|
|
||||||
product_barcode: string;
|
|
||||||
/** Brand id, 0 when none. */
|
|
||||||
product_brand: number;
|
|
||||||
/** Supplier id, 0 when none. */
|
|
||||||
product_supplier: number;
|
|
||||||
/** Decimal as a string, e.g. "13.99". */
|
|
||||||
product_price: string;
|
|
||||||
/** "0.00" when the product has no discount. */
|
|
||||||
product_discount_price: string;
|
|
||||||
product_supply_price: string;
|
|
||||||
/** Category id, 0 when none. */
|
|
||||||
product_category: number;
|
|
||||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
|
||||||
product_display_www: number;
|
|
||||||
/** Archived: 1 = yes, 0 = no. */
|
|
||||||
product_arch: number;
|
|
||||||
products_desc: string;
|
|
||||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
|
||||||
updated_at: string;
|
|
||||||
/** Per-size overrides of the product's price, discount and barcode. */
|
|
||||||
product_specific_rules: ProductSpecificRulesHiboutikDto[];
|
|
||||||
/** Sizes of the product; missing or empty for a product without sizes. */
|
|
||||||
product_size_details?: ProductSizeDetailsHiboutikDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
|
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
|
||||||
export interface ProductSpecificRulesHiboutikDto {
|
export const productSpecificRulesHiboutikSchema = z.object({
|
||||||
size_id: number;
|
size_id: hiboutikRef,
|
||||||
barcode: string;
|
product_price: hiboutikText,
|
||||||
product_price?: string;
|
product_discount_price: hiboutikText,
|
||||||
product_discount_price?: string;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/** One size of a product (e.g. "S", "M"). */
|
/** One size of a product (e.g. "S", "M"). */
|
||||||
export interface ProductSizeDetailsHiboutikDto {
|
export const productSizeDetailsHiboutikSchema = z.object({
|
||||||
size_id: number;
|
size_id: hiboutikId,
|
||||||
size_name: string;
|
size_name: hiboutikText,
|
||||||
/** Empty string when the size has no barcode. */
|
/** Empty when the size has no barcode. */
|
||||||
barcode: string;
|
barcode: hiboutikText,
|
||||||
}
|
});
|
||||||
|
|
||||||
|
/** Product as returned by Hiboutik `GET /products`, archived products included (only the fields used by the sync). */
|
||||||
|
export const productHiboutikSchema = z.object({
|
||||||
|
product_id: hiboutikId,
|
||||||
|
/** Product name. */
|
||||||
|
product_model: hiboutikText,
|
||||||
|
/** Empty when the product has no barcode. */
|
||||||
|
product_barcode: hiboutikText,
|
||||||
|
/** Brand id, 0 when none. */
|
||||||
|
product_brand: hiboutikRef,
|
||||||
|
/** Supplier id, 0 when none. */
|
||||||
|
product_supplier: hiboutikRef,
|
||||||
|
/** e.g. "13.99". */
|
||||||
|
product_price: hiboutikDecimal,
|
||||||
|
/** "0.00" when the product has no discount. */
|
||||||
|
product_discount_price: hiboutikText,
|
||||||
|
/** Category id, 0 when none. */
|
||||||
|
product_category: hiboutikRef,
|
||||||
|
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||||
|
product_display_www: hiboutikInt,
|
||||||
|
/** Archived: 1 = yes, 0 = no. */
|
||||||
|
product_arch: hiboutikInt,
|
||||||
|
products_desc: hiboutikText,
|
||||||
|
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
||||||
|
updated_at: hiboutikText,
|
||||||
|
/** Per-size overrides of the product's price, discount and barcode. */
|
||||||
|
product_specific_rules: hiboutikList(productSpecificRulesHiboutikSchema),
|
||||||
|
/** Sizes of the product; empty for a product without sizes. */
|
||||||
|
product_size_details: hiboutikList(productSizeDetailsHiboutikSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProductSpecificRulesHiboutikDto = z.output<typeof productSpecificRulesHiboutikSchema>;
|
||||||
|
export type ProductSizeDetailsHiboutikDto = z.output<typeof productSizeDetailsHiboutikSchema>;
|
||||||
|
export type ProductHiboutikDto = z.output<typeof productHiboutikSchema>;
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
/**
|
import { z } from 'zod';
|
||||||
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines.
|
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
||||||
* 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. */
|
/** Line of a sale: a snapshot of the product at sale time. */
|
||||||
export interface SaleLineHiboutikDto {
|
export const saleLineHiboutikSchema = z.object({
|
||||||
line_item_id: number;
|
product_id: hiboutikRef,
|
||||||
product_id: number;
|
|
||||||
/** Hiboutik size id, 0 for a product without sizes. */
|
/** Hiboutik size id, 0 for a product without sizes. */
|
||||||
product_size: number;
|
product_size: hiboutikRef,
|
||||||
/** Negative for a returned item. */
|
/** Negative for a returned item. */
|
||||||
quantity: number;
|
quantity: hiboutikInt,
|
||||||
product_model: string;
|
product_model: hiboutikText,
|
||||||
product_barcode: string;
|
product_barcode: hiboutikText,
|
||||||
/** Size name, empty for a product without sizes. */
|
/** Size name, empty for a product without sizes. */
|
||||||
size_name: string;
|
size_name: hiboutikText,
|
||||||
/** Unit price before discount, taxes included (decimal as a string). */
|
/** Unit price actually paid, discount deducted, taxes included. */
|
||||||
item_unit_gross_wo_discount: string;
|
item_unit_gross: hiboutikDecimal,
|
||||||
/** Unit price actually paid, discount deducted, taxes included (decimal as a string). */
|
});
|
||||||
item_unit_gross: string;
|
|
||||||
}
|
/**
|
||||||
|
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. Only the fields used by the sync: the payload
|
||||||
|
* also carries many computed copies (dates split in parts, totals by tax, customer and store details...).
|
||||||
|
*/
|
||||||
|
export const saleHiboutikSchema = z.object({
|
||||||
|
sale_id: hiboutikId,
|
||||||
|
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
||||||
|
created_at: hiboutikText,
|
||||||
|
/** French local time, "0000-00-00 00:00:00" while the sale is not completed. */
|
||||||
|
completed_at: hiboutikText,
|
||||||
|
/** 0 for an anonymous sale. */
|
||||||
|
customer_id: hiboutikRef,
|
||||||
|
/** Taxes included. */
|
||||||
|
total: hiboutikDecimal,
|
||||||
|
/** 1 for a quotation. */
|
||||||
|
quotation: hiboutikInt,
|
||||||
|
line_items: hiboutikList(saleLineHiboutikSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SaleLineHiboutikDto = z.output<typeof saleLineHiboutikSchema>;
|
||||||
|
export type SaleHiboutikDto = z.output<typeof saleHiboutikSchema>;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { hiboutikId, hiboutikNumber, hiboutikRef } from './hiboutik.schema';
|
||||||
|
|
||||||
/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */
|
/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */
|
||||||
export interface StockHiboutikDto {
|
export const stockHiboutikSchema = z.object({
|
||||||
product_id: number;
|
product_id: hiboutikId,
|
||||||
/** Size id, 0 for a product without sizes. */
|
/** Size id, 0 for a product without sizes. */
|
||||||
product_size: number;
|
product_size: hiboutikRef,
|
||||||
/** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
/** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
||||||
stock_available: string;
|
stock_available: hiboutikNumber,
|
||||||
product_barcode: string;
|
});
|
||||||
product_supplier_reference: string;
|
|
||||||
}
|
export type StockHiboutikDto = z.output<typeof stockHiboutikSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/** Supplier as returned by Hiboutik `GET /suppliers`. */
|
import { z } from 'zod';
|
||||||
export interface SupplierHiboutikDto {
|
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
||||||
supplier_id: number;
|
|
||||||
supplier_name: string;
|
/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */
|
||||||
|
export const supplierHiboutikSchema = z.object({
|
||||||
|
supplier_id: hiboutikId,
|
||||||
|
supplier_name: hiboutikText,
|
||||||
/** 1 = enabled, 0 = disabled. */
|
/** 1 = enabled, 0 = disabled. */
|
||||||
supplier_enabled: number;
|
supplier_enabled: hiboutikInt,
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export type SupplierHiboutikDto = z.output<typeof supplierHiboutikSchema>;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class BrandHiboutikService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
||||||
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id)));
|
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { CategoryHiboutikService } from './category.hiboutik.service';
|
|||||||
import { CategoryConverter } from '../../models/converter/category.converter';
|
import { CategoryConverter } from '../../models/converter/category.converter';
|
||||||
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
|
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto =>
|
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto => ({
|
||||||
({
|
|
||||||
category_id: id,
|
category_id: id,
|
||||||
category_name: `Category ${id}`,
|
category_name: `Category ${id}`,
|
||||||
category_id_parent: parentId,
|
category_id_parent: parentId,
|
||||||
@@ -12,7 +11,7 @@ const hiboutikCategory = (id: number, parentId: number, overrides: Partial<Categ
|
|||||||
category_enabled_www: 0,
|
category_enabled_www: 0,
|
||||||
category_position: 3,
|
category_position: 3,
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as CategoryHiboutikDto;
|
});
|
||||||
|
|
||||||
describe('CategoryHiboutikService', () => {
|
describe('CategoryHiboutikService', () => {
|
||||||
const categoryHiboutikRepository = { getCategories: jest.fn() };
|
const categoryHiboutikRepository = { getCategories: jest.fn() };
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ export class CategoryHiboutikService {
|
|||||||
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
|
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
|
||||||
const nbParentsFailed = await forEachSafely(
|
const nbParentsFailed = await forEachSafely(
|
||||||
hiboutikCategories,
|
hiboutikCategories,
|
||||||
(dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null),
|
(dto) => this.categoryDatabaseRepository.setParent(dto.category_id, dto.category_id_parent || null),
|
||||||
this.logger,
|
this.logger,
|
||||||
(dto) => `parent of Hiboutik category ${dto.category_id}`,
|
(dto) => `parent of Hiboutik category ${dto.category_id}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
||||||
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id)));
|
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
|
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
|
||||||
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
|
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ 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(), getAddressById: jest.fn() };
|
const customerHiboutikRepository = {
|
||||||
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
getCustomers: jest.fn(),
|
||||||
|
getCustomerById: jest.fn(),
|
||||||
|
findCustomerById: jest.fn(),
|
||||||
|
getAddressById: jest.fn(),
|
||||||
|
};
|
||||||
|
const customerDatabaseRepository = { upsert: jest.fn(), getAllHiboutikIds: jest.fn(), deleteByHiboutikIds: jest.fn() };
|
||||||
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) };
|
||||||
@@ -23,7 +28,9 @@ describe('CustomerHiboutikService', () => {
|
|||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||||
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([]);
|
||||||
|
customerDatabaseRepository.deleteByHiboutikIds.mockImplementation((ids: number[]) => Promise.resolve(ids.length));
|
||||||
|
customerHiboutikRepository.findCustomerById.mockResolvedValue(null);
|
||||||
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||||
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
||||||
@@ -39,27 +46,61 @@ describe('CustomerHiboutikService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes customers missing from Hiboutik', async () => {
|
describe('deletions', () => {
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]);
|
it('deletes the stored customers missing from the list once Hiboutik confirms they no longer exist', async () => {
|
||||||
|
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]);
|
||||||
|
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2, 3, 4]);
|
||||||
|
|
||||||
await service.syncCustomers();
|
await service.syncCustomers();
|
||||||
|
|
||||||
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]);
|
||||||
|
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]);
|
||||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes every customer when Hiboutik answers with an empty list', async () => {
|
it('keeps and syncs a customer missing from the list (page shift) that still exists in Hiboutik', async () => {
|
||||||
|
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||||
|
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]);
|
||||||
|
customerHiboutikRepository.findCustomerById.mockResolvedValue({ customers_id: 2 });
|
||||||
|
|
||||||
await service.syncCustomers();
|
await service.syncCustomers();
|
||||||
|
|
||||||
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]);
|
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]);
|
||||||
|
expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes nothing when the Hiboutik call fails', async () => {
|
it('keeps a customer whose check fails', async () => {
|
||||||
|
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]);
|
||||||
|
customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500'));
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes every confirmed customer when Hiboutik answers with an empty list', async () => {
|
||||||
|
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]);
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes nothing when the Hiboutik list call fails', async () => {
|
||||||
customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500'));
|
customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500'));
|
||||||
|
|
||||||
await expect(service.syncCustomers()).rejects.toThrow('500');
|
await expect(service.syncCustomers()).rejects.toThrow('500');
|
||||||
|
|
||||||
expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
expect(customerDatabaseRepository.deleteByHiboutikIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs once a customer listed twice', async () => {
|
||||||
|
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 1 }]);
|
||||||
|
|
||||||
|
await service.syncCustomers();
|
||||||
|
|
||||||
|
expect(customerHiboutikRepository.getCustomerById).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps syncing the other customers when one fails', async () => {
|
it('keeps syncing the other customers when one fails', async () => {
|
||||||
@@ -80,8 +121,8 @@ describe('CustomerHiboutikService', () => {
|
|||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
customers_id: 1,
|
customers_id: 1,
|
||||||
addresses: [
|
addresses: [
|
||||||
{ address_id: 10, customers_id: 1 },
|
{ address_id: 10, customers_id: 1, company: '' },
|
||||||
{ address_id: '11', customers_id: 999 },
|
{ address_id: 11, customers_id: 999, company: '' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +130,7 @@ describe('CustomerHiboutikService', () => {
|
|||||||
|
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
||||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 });
|
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' });
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
||||||
);
|
);
|
||||||
@@ -155,7 +196,10 @@ describe('CustomerHiboutikService', () => {
|
|||||||
it('keeps syncing the other addresses when one fails', async () => {
|
it('keeps syncing the other addresses when one fails', async () => {
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
customers_id: 1,
|
customers_id: 1,
|
||||||
addresses: [{ address_id: 10 }, { address_id: 11 }],
|
addresses: [
|
||||||
|
{ address_id: 10, company: '' },
|
||||||
|
{ address_id: 11, company: '' },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
@@ -171,7 +215,7 @@ describe('CustomerHiboutikService', () => {
|
|||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||||
customers_id: 1,
|
customers_id: 1,
|
||||||
addresses: [],
|
addresses: [],
|
||||||
sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }],
|
sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +224,7 @@ describe('CustomerHiboutikService', () => {
|
|||||||
|
|
||||||
await service.syncCustomers();
|
await service.syncCustomers();
|
||||||
|
|
||||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]);
|
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith(1, [5, 6, 7]);
|
||||||
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
||||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { forEachSafely } from '../../utils/sync';
|
|||||||
import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository';
|
import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository';
|
||||||
import { OrderDatabaseRepository } from '../../database/order.database.repository';
|
import { OrderDatabaseRepository } from '../../database/order.database.repository';
|
||||||
import { OrderConverter } from '../../models/converter/order.converter';
|
import { OrderConverter } from '../../models/converter/order.converter';
|
||||||
|
import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerHiboutikService {
|
export class CustomerHiboutikService {
|
||||||
@@ -30,35 +31,66 @@ export class CustomerHiboutikService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
* Mirrors Hiboutik customers: deletes the customers Hiboutik confirms as deleted, then fetches and upserts each of
|
||||||
* with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one
|
* them with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus
|
||||||
* per sale not yet stored as completed).
|
* one per sale not yet stored as completed).
|
||||||
*/
|
*/
|
||||||
async syncCustomers() {
|
async syncCustomers() {
|
||||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||||
|
// A page shift can list a customer twice: sync it once
|
||||||
|
const listedIds = [...new Set(hiboutikCustomers.map((dto) => dto.customers_id))];
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
const { nbDeleted, unlistedIds } = await this.deleteMissingCustomers(listedIds);
|
||||||
const nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id)));
|
const customerIds = [...listedIds, ...unlistedIds];
|
||||||
|
|
||||||
let nbFailed = 0;
|
let nbFailed = 0;
|
||||||
let nbFailedAddresses = 0;
|
let nbFailedAddresses = 0;
|
||||||
let nbFailedOrders = 0;
|
let nbFailedOrders = 0;
|
||||||
for (const hiboutikCustomer of hiboutikCustomers) {
|
for (const customerId of customerIds) {
|
||||||
try {
|
try {
|
||||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(customerId);
|
||||||
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);
|
||||||
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rethrowIfRateLimited(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 ${customerId}`, (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, ${nbFailedOrders} orders failed.`,
|
`Customers sync ended: ${customerIds.length - nbFailed}/${customerIds.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ` +
|
||||||
|
`${unlistedIds.length} missing from the list but still in Hiboutik, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the stored customers missing from the Hiboutik list, once Hiboutik confirms one by one that they no longer
|
||||||
|
* exist. The list is paginated: a customer created or deleted in Hiboutik while it is walked shifts the following
|
||||||
|
* pages, so a customer can be missing from it without being deleted. Hiboutik is trusted: a 404 or an empty 2xx
|
||||||
|
* answer is a confirmation. A customer whose check fails is kept.
|
||||||
|
* @returns the number of deleted customers, and the ids of the customers that still exist although not listed.
|
||||||
|
*/
|
||||||
|
private async deleteMissingCustomers(listedIds: number[]): Promise<{ nbDeleted: number; unlistedIds: number[] }> {
|
||||||
|
const listed = new Set(listedIds);
|
||||||
|
const missingIds = (await this.customerDatabaseRepository.getAllHiboutikIds()).filter((id) => !listed.has(id));
|
||||||
|
|
||||||
|
const deletedIds: number[] = [];
|
||||||
|
const unlistedIds: number[] = [];
|
||||||
|
for (const id of missingIds) {
|
||||||
|
try {
|
||||||
|
const customer = await this.customerHiboutikRepository.findCustomerById(id);
|
||||||
|
(customer ? unlistedIds : deletedIds).push(id);
|
||||||
|
} catch (error) {
|
||||||
|
rethrowIfRateLimited(error);
|
||||||
|
this.logger.warn(`Failed to check whether Hiboutik customer ${id} still exists, it is kept: ${(error as Error)?.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nbDeleted = await this.customerDatabaseRepository.deleteByHiboutikIds(deletedIds);
|
||||||
|
return { nbDeleted, unlistedIds };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts
|
* 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).
|
* each of them (a failing address is logged and skipped).
|
||||||
@@ -73,13 +105,13 @@ export class CustomerHiboutikService {
|
|||||||
|
|
||||||
await this.customerAddressDatabaseRepository.deleteMissing(
|
await this.customerAddressDatabaseRepository.deleteMissing(
|
||||||
customerId,
|
customerId,
|
||||||
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
|
hiboutikCustomer.addresses.map((dto) => dto.address_id),
|
||||||
);
|
);
|
||||||
return forEachSafely(
|
return forEachSafely(
|
||||||
hiboutikCustomer.addresses,
|
hiboutikCustomer.addresses,
|
||||||
// Linked to the parent customer, whatever the embedded customers_id says
|
// Linked to the parent customer, whatever the embedded customers_id says
|
||||||
async (dto) => {
|
async (dto) => {
|
||||||
const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto;
|
const address = dto.company.trim() ? await this.withCompanyDetails(dto) : dto;
|
||||||
await this.customerAddressDatabaseRepository.upsert(
|
await this.customerAddressDatabaseRepository.upsert(
|
||||||
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
||||||
);
|
);
|
||||||
@@ -106,6 +138,7 @@ export class CustomerHiboutikService {
|
|||||||
updated_at: details.updated_at,
|
updated_at: details.updated_at,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rethrowIfRateLimited(error);
|
||||||
this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`);
|
this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`);
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -124,15 +157,16 @@ export class CustomerHiboutikService {
|
|||||||
|
|
||||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
||||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
||||||
hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)),
|
hiboutikCustomer.customers_id,
|
||||||
|
hiboutikCustomer.sales.map((dto) => dto.sale_id),
|
||||||
);
|
);
|
||||||
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id)));
|
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(dto.sale_id));
|
||||||
|
|
||||||
return forEachSafely(
|
return forEachSafely(
|
||||||
salesToSync,
|
salesToSync,
|
||||||
async (dto) => {
|
async (dto) => {
|
||||||
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
||||||
if (Number(sale.quotation) === 1) return;
|
if (sale.quotation === 1) return;
|
||||||
// Linked to the parent customer, whatever the sale says
|
// Linked to the parent customer, whatever the sale says
|
||||||
await this.orderDatabaseRepository.upsert(
|
await this.orderDatabaseRepository.upsert(
|
||||||
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
||||||
|
|||||||
@@ -12,22 +12,20 @@ const hiboutikProduct = (id: number, overrides: Partial<ProductHiboutikDto> = {}
|
|||||||
product_supplier: 0,
|
product_supplier: 0,
|
||||||
product_price: '10.00',
|
product_price: '10.00',
|
||||||
product_discount_price: '0.00',
|
product_discount_price: '0.00',
|
||||||
product_supply_price: '5.00',
|
|
||||||
product_category: 0,
|
product_category: 0,
|
||||||
product_display_www: 1,
|
product_display_www: 1,
|
||||||
product_arch: 0,
|
product_arch: 0,
|
||||||
products_desc: '',
|
products_desc: '',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
updated_at: '2026-06-28 17:02:55',
|
||||||
product_specific_rules: [],
|
product_specific_rules: [],
|
||||||
|
product_size_details: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({
|
const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
product_size: size,
|
product_size: size,
|
||||||
stock_available: available,
|
stock_available: available,
|
||||||
product_barcode: '',
|
|
||||||
product_supplier_reference: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ProductHiboutikService', () => {
|
describe('ProductHiboutikService', () => {
|
||||||
@@ -37,6 +35,9 @@ describe('ProductHiboutikService', () => {
|
|||||||
const stockHiboutikRepository = { getStocks: jest.fn() };
|
const stockHiboutikRepository = { getStocks: jest.fn() };
|
||||||
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
|
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
|
||||||
const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
|
// The transaction client handed to the repositories inside $transaction
|
||||||
|
const tx = { transaction: true };
|
||||||
|
const prisma = { $transaction: jest.fn() };
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
@@ -47,6 +48,7 @@ describe('ProductHiboutikService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
calls = [];
|
calls = [];
|
||||||
|
prisma.$transaction.mockImplementation((run: (client: unknown) => Promise<unknown>) => run(tx));
|
||||||
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
||||||
productDatabaseRepository.getAll.mockResolvedValue([]);
|
productDatabaseRepository.getAll.mockResolvedValue([]);
|
||||||
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
||||||
@@ -73,6 +75,7 @@ describe('ProductHiboutikService', () => {
|
|||||||
productVersionDatabaseRepository as never,
|
productVersionDatabaseRepository as never,
|
||||||
new ProductConverter(),
|
new ProductConverter(),
|
||||||
stockHiboutikRepository as never,
|
stockHiboutikRepository as never,
|
||||||
|
prisma as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,16 +112,17 @@ describe('ProductHiboutikService', () => {
|
|||||||
|
|
||||||
it('creates a single version for a product without sizes', async () => {
|
it('creates a single version for a product without sizes', async () => {
|
||||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
||||||
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, '4'), stock(2, 0, '99')]);
|
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, 4), stock(2, 0, 99)]);
|
||||||
|
|
||||||
await service.syncProducts();
|
await service.syncProducts();
|
||||||
|
|
||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }));
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }), tx);
|
||||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]);
|
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0], tx);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith(
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith(
|
||||||
'uuid-1',
|
'uuid-1',
|
||||||
expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }),
|
expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,17 +133,20 @@ describe('ProductHiboutikService', () => {
|
|||||||
{ size_id: 10, size_name: 'S', barcode: 'b-s' },
|
{ size_id: 10, size_name: 'S', barcode: 'b-s' },
|
||||||
{ size_id: 11, size_name: 'M', barcode: '' },
|
{ size_id: 11, size_name: 'M', barcode: '' },
|
||||||
],
|
],
|
||||||
product_specific_rules: [{ size_id: 10, barcode: 'b-s', product_price: '12.00', product_discount_price: '0.00' }],
|
product_specific_rules: [{ size_id: 10, product_price: '12.00', product_discount_price: '0.00' }],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, '3'), stock(1, 11, '2')]);
|
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, 3), stock(1, 11, 2)]);
|
||||||
|
|
||||||
await service.syncProducts();
|
await service.syncProducts();
|
||||||
|
|
||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }));
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }), tx);
|
||||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11]);
|
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11], tx);
|
||||||
expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']);
|
expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(1, 'uuid-1', {
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'uuid-1',
|
||||||
|
{
|
||||||
barcode: 'b-s',
|
barcode: 'b-s',
|
||||||
discountPrice: null,
|
discountPrice: null,
|
||||||
hiboutikSizeId: 10,
|
hiboutikSizeId: 10,
|
||||||
@@ -147,8 +154,13 @@ describe('ProductHiboutikService', () => {
|
|||||||
product: { connect: { id: 'uuid-1' } },
|
product: { connect: { id: 'uuid-1' } },
|
||||||
stock: 3,
|
stock: 3,
|
||||||
versionName: 'S',
|
versionName: 'S',
|
||||||
});
|
},
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', {
|
tx,
|
||||||
|
);
|
||||||
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'uuid-1',
|
||||||
|
{
|
||||||
barcode: null,
|
barcode: null,
|
||||||
discountPrice: null,
|
discountPrice: null,
|
||||||
hiboutikSizeId: 11,
|
hiboutikSizeId: 11,
|
||||||
@@ -156,7 +168,9 @@ describe('ProductHiboutikService', () => {
|
|||||||
product: { connect: { id: 'uuid-1' } },
|
product: { connect: { id: 'uuid-1' } },
|
||||||
stock: 2,
|
stock: 2,
|
||||||
versionName: 'M',
|
versionName: 'M',
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps syncing the other products when one fails', async () => {
|
it('keeps syncing the other products when one fails', async () => {
|
||||||
@@ -168,4 +182,16 @@ describe('ProductHiboutikService', () => {
|
|||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes a product and its versions in one transaction per product, and counts a failed one', async () => {
|
||||||
|
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]);
|
||||||
|
productVersionDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
await expect(service.syncProducts()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(prisma.$transaction).toHaveBeenCalledTimes(2);
|
||||||
|
// the failure inside the transaction is what makes Prisma roll the product back
|
||||||
|
await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom');
|
||||||
|
await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
|
|||||||
import { Product } from '../../../prisma/generated/client';
|
import { Product } from '../../../prisma/generated/client';
|
||||||
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
|
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
|
||||||
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client';
|
||||||
|
|
||||||
/** Version id used for products that have no sizes in Hiboutik. */
|
/** Version id used for products that have no sizes in Hiboutik. */
|
||||||
const NO_SIZE_ID = 0;
|
const NO_SIZE_ID = 0;
|
||||||
@@ -23,6 +25,7 @@ export class ProductHiboutikService {
|
|||||||
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
|
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
|
||||||
private readonly productConverter: ProductConverter,
|
private readonly productConverter: ProductConverter,
|
||||||
private readonly stockHiboutikRepository: StockHiboutikRepository,
|
private readonly stockHiboutikRepository: StockHiboutikRepository,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
|
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
|
||||||
@@ -33,16 +36,24 @@ export class ProductHiboutikService {
|
|||||||
this.productDatabaseRepository.getAll(),
|
this.productDatabaseRepository.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Deletion happens before upserts so that a product recreated in Hiboutik (new id, same barcode)
|
// Deletion happens before the upserts, so that order lines of a deleted product are unlinked before anything else changes
|
||||||
// does not collide with the old one on the unique barcode.
|
|
||||||
const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts);
|
const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts);
|
||||||
|
|
||||||
|
// Grouped once by product: one pass over the stocks instead of one per product
|
||||||
|
const stocksByProduct = new Map<number, StockHiboutikDto[]>();
|
||||||
|
for (const stock of hiboutikStocks) {
|
||||||
|
const productStocks = stocksByProduct.get(stock.product_id);
|
||||||
|
if (productStocks) productStocks.push(stock);
|
||||||
|
else stocksByProduct.set(stock.product_id, [stock]);
|
||||||
|
}
|
||||||
|
|
||||||
let nbFailed = 0;
|
let nbFailed = 0;
|
||||||
for (const productHiboutik of productsHiboutik) {
|
for (const productHiboutik of productsHiboutik) {
|
||||||
const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id));
|
const hiboutikProductStocks = stocksByProduct.get(productHiboutik.product_id) ?? [];
|
||||||
try {
|
try {
|
||||||
await this.updateProduct(productHiboutik, hiboutikProductStocks);
|
await this.updateProduct(productHiboutik, hiboutikProductStocks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rethrowIfRateLimited(error);
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
|
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
@@ -56,27 +67,35 @@ export class ProductHiboutikService {
|
|||||||
/** @returns the number of deleted products. */
|
/** @returns the number of deleted products. */
|
||||||
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
|
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
||||||
const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id)));
|
const hiboutikIds = new Set(productsHiboutik.map((product) => product.product_id));
|
||||||
const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id);
|
const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id);
|
||||||
return this.productDatabaseRepository.deleteAll(missingProductIds);
|
return this.productDatabaseRepository.deleteAll(missingProductIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upserts a product with its global stock, then replaces its versions. */
|
/**
|
||||||
|
* Upserts a product with its global stock, then replaces its versions, in a single transaction: on failure, nothing
|
||||||
|
* is changed (no global stock out of step with the versions).
|
||||||
|
*/
|
||||||
private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise<void> {
|
private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise<void> {
|
||||||
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
|
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
|
||||||
newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0);
|
newProduct.globalStock = productStocks.reduce((sum, stock) => sum + stock.stock_available, 0);
|
||||||
|
|
||||||
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct);
|
await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct, tx);
|
||||||
const versions = this.buildVersions(productHiboutik, productStocks, savedProduct);
|
const versions = this.buildVersions(productHiboutik, productStocks, savedProduct);
|
||||||
|
|
||||||
// Same reason as for products: delete removed versions first to free their barcodes
|
|
||||||
await this.productVersionDatabaseRepository.deleteMissing(
|
await this.productVersionDatabaseRepository.deleteMissing(
|
||||||
savedProduct.id,
|
savedProduct.id,
|
||||||
versions.map((version) => version.hiboutikSizeId),
|
versions.map((version) => version.hiboutikSizeId),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
await this.productVersionDatabaseRepository.upsert(savedProduct.id, version);
|
await this.productVersionDatabaseRepository.upsert(savedProduct.id, version, tx);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,8 +107,8 @@ export class ProductHiboutikService {
|
|||||||
productStocks: StockHiboutikDto[],
|
productStocks: StockHiboutikDto[],
|
||||||
savedProduct: Product,
|
savedProduct: Product,
|
||||||
): ProductVersionCreateInput[] {
|
): ProductVersionCreateInput[] {
|
||||||
const specificRules = productHiboutik.product_specific_rules ?? [];
|
const specificRules = productHiboutik.product_specific_rules;
|
||||||
const sizesDetails = productHiboutik.product_size_details ?? [];
|
const sizesDetails = productHiboutik.product_size_details;
|
||||||
|
|
||||||
if (sizesDetails.length === 0) {
|
if (sizesDetails.length === 0) {
|
||||||
return [
|
return [
|
||||||
@@ -106,8 +125,8 @@ export class ProductHiboutikService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sizesDetails.map((sizeDetails) => {
|
return sizesDetails.map((sizeDetails) => {
|
||||||
const sizeId = Number(sizeDetails.size_id);
|
const sizeId = sizeDetails.size_id;
|
||||||
const specificRule = specificRules.find((rule) => Number(rule.size_id) === sizeId);
|
const specificRule = specificRules.find((rule) => rule.size_id === sizeId);
|
||||||
const stock = productStocks.find((stock) => stock.product_size === sizeId);
|
const stock = productStocks.find((stock) => stock.product_size === sizeId);
|
||||||
// Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep
|
// Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep
|
||||||
// the previous value on update when a rule or a stock is removed in Hiboutik.
|
// the previous value on update when a rule or a stock is removed in Hiboutik.
|
||||||
@@ -117,7 +136,7 @@ export class ProductHiboutikService {
|
|||||||
hiboutikSizeId: sizeId,
|
hiboutikSizeId: sizeId,
|
||||||
price: parseOptionalHiboutikPrice(specificRule?.product_price),
|
price: parseOptionalHiboutikPrice(specificRule?.product_price),
|
||||||
product: { connect: { id: savedProduct.id } },
|
product: { connect: { id: savedProduct.id } },
|
||||||
stock: stock ? Number(stock.stock_available) : 0,
|
stock: stock?.stock_available ?? 0,
|
||||||
versionName: sizeDetails.size_name,
|
versionName: sizeDetails.size_name,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class SupplierHiboutikService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
||||||
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id)));
|
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => dto.supplier_id));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { parseHiboutikDate } from './date';
|
import { parseHiboutikDate } from './date';
|
||||||
|
|
||||||
describe('parseHiboutikDate', () => {
|
describe('parseHiboutikDate', () => {
|
||||||
it('parses a Hiboutik datetime', () => {
|
it('parses a Hiboutik datetime as French summer time (UTC+2)', () => {
|
||||||
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55'));
|
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T15:02:55Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a date without time', () => {
|
it('parses a Hiboutik datetime as French winter time (UTC+1)', () => {
|
||||||
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12'));
|
expect(parseHiboutikDate('2026-01-15 10:00:00')).toEqual(new Date('2026-01-15T09:00:00Z'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => {
|
it('switches offset at the DST changes', () => {
|
||||||
|
expect(parseHiboutikDate('2026-03-29 01:59:59')).toEqual(new Date('2026-03-29T00:59:59Z'));
|
||||||
|
expect(parseHiboutikDate('2026-03-29 03:00:00')).toEqual(new Date('2026-03-29T01:00:00Z'));
|
||||||
|
expect(parseHiboutikDate('2026-10-25 03:00:00')).toEqual(new Date('2026-10-25T02:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a date without time as midnight UTC (calendar date)', () => {
|
||||||
|
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12T00:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'0000-00-00',
|
||||||
|
'0000-00-00 00:00:00',
|
||||||
|
'2026-02-30',
|
||||||
|
'2026-13-01 10:00:00',
|
||||||
|
'2026-06-28 25:00:00',
|
||||||
|
'not a date',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
])('returns undefined for %p', (value) => {
|
||||||
expect(parseHiboutikDate(value)).toBeUndefined();
|
expect(parseHiboutikDate(value)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,53 @@
|
|||||||
|
/** Time zone of the dates sent by Hiboutik: the shop's local (French) time, without any offset. */
|
||||||
|
export const HIBOUTIK_TIME_ZONE = 'Europe/Paris';
|
||||||
|
|
||||||
|
const HIBOUTIK_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
||||||
|
|
||||||
|
const timeZoneFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: HIBOUTIK_TIME_ZONE,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Offset (ms) of {@link HIBOUTIK_TIME_ZONE} from UTC at the given instant: +1 h in winter, +2 h in summer. */
|
||||||
|
function timeZoneOffset(instant: number): number {
|
||||||
|
const parts = Object.fromEntries(timeZoneFormatter.formatToParts(new Date(instant)).map((part) => [part.type, Number(part.value)]));
|
||||||
|
const wallClockAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
||||||
|
return wallClockAsUtc - instant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD").
|
* Parses a Hiboutik date. Returns undefined for empty or invalid dates (e.g. "0000-00-00", "2026-02-30").
|
||||||
* Returns undefined for empty or invalid dates (e.g. "0000-00-00").
|
* - "YYYY-MM-DD HH:mm:ss": French local time ({@link HIBOUTIK_TIME_ZONE}), whatever the time zone of the server.
|
||||||
|
* A time that is ambiguous or skipped at a DST change (02:00-03:00) resolves to a nearby valid instant.
|
||||||
|
* - "YYYY-MM-DD" (calendar date, e.g. a date of birth): midnight UTC, so that the stored date is the same day.
|
||||||
*/
|
*/
|
||||||
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
|
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
|
||||||
if (!value) return undefined;
|
const match = HIBOUTIK_DATE.exec(value?.trim() ?? '');
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
const date = new Date(value.trim().replace(' ', 'T'));
|
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
||||||
return isNaN(date.getTime()) ? undefined : date;
|
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||||
|
|
||||||
|
// Rejects out-of-range values (month 0 or 13, February 30, 25:00...), which Date.UTC would silently roll over
|
||||||
|
const check = new Date(wallClockAsUtc);
|
||||||
|
const isValid =
|
||||||
|
check.getUTCFullYear() === year &&
|
||||||
|
check.getUTCMonth() === month - 1 &&
|
||||||
|
check.getUTCDate() === day &&
|
||||||
|
check.getUTCHours() === hour &&
|
||||||
|
check.getUTCMinutes() === minute &&
|
||||||
|
check.getUTCSeconds() === second;
|
||||||
|
if (!isValid) return undefined;
|
||||||
|
|
||||||
|
if (match[4] === undefined) return check;
|
||||||
|
|
||||||
|
// The offset depends on the instant itself (DST): guess it from the wall clock, then correct it once
|
||||||
|
const firstGuess = wallClockAsUtc - timeZoneOffset(wallClockAsUtc);
|
||||||
|
return new Date(wallClockAsUtc - timeZoneOffset(firstGuess));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { rethrowIfRateLimited } from '../hiboutik/hiboutik.client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed.
|
* Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed.
|
||||||
|
* A Hiboutik rate limit error stops the loop (rethrown): the next items would fail the same way.
|
||||||
* @param items
|
* @param items
|
||||||
* @param action
|
* @param action
|
||||||
* @param logger
|
* @param logger
|
||||||
@@ -19,6 +21,7 @@ export async function forEachSafely<T>(
|
|||||||
try {
|
try {
|
||||||
await action(item);
|
await action(item);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rethrowIfRateLimited(error);
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
|
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user