feat(sync): add converters from Hiboutik DTOs to Prisma inputs
This commit is contained in:
19
src/models/converter/brand.converter.ts
Normal file
19
src/models/converter/brand.converter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { BrandHiboutikDto } from '../hiboutikDTO/brand.hiboutik.dto';
|
||||
import { BrandCreateInput } from '../../../prisma/generated/models/Brand';
|
||||
|
||||
@Injectable()
|
||||
export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto> {
|
||||
fromHiboutik(dto: BrandHiboutikDto): BrandCreateInput {
|
||||
return {
|
||||
hiboutikId: dto.brand_id,
|
||||
name: dto.brand_name,
|
||||
enabled: dto.brand_enabled == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): BrandHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
22
src/models/converter/category.converter.ts
Normal file
22
src/models/converter/category.converter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CategoryHiboutikDto } from '../hiboutikDTO/category.hiboutik.dto';
|
||||
import { Converter } from './converter';
|
||||
import { CategoryCreateInput } from '../../../prisma/generated/models/Category';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHiboutikDto> {
|
||||
/** The parent is not set here: it is linked afterwards, once every category exists (see CategoryHiboutikService). */
|
||||
fromHiboutik(hiboutikDto: CategoryHiboutikDto): CategoryCreateInput {
|
||||
return {
|
||||
hiboutikId: hiboutikDto.category_id,
|
||||
name: hiboutikDto.category_name,
|
||||
enabled: hiboutikDto.category_enabled == 1,
|
||||
position: Number(hiboutikDto.category_position) || 0,
|
||||
webEnabled: hiboutikDto.category_enabled_www == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): CategoryHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
29
src/models/converter/converter.ts
Normal file
29
src/models/converter/converter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Maps a Hiboutik DTO to a Prisma input, and back. One implementation per synced entity.
|
||||
* @typeParam Model Prisma `*CreateInput` of the entity.
|
||||
* @typeParam Dto Hiboutik payload of the entity.
|
||||
*/
|
||||
export abstract class Converter<Model, Dto> {
|
||||
/**
|
||||
* Builds the Prisma input from a Hiboutik DTO. It is used for both create and update (upsert):
|
||||
* a field set to `undefined` keeps its stored value on update, `null` erases it.
|
||||
*/
|
||||
abstract fromHiboutik(dto: Dto): Model;
|
||||
|
||||
/** Not implemented yet: nothing is written back to Hiboutik. */
|
||||
abstract toHiboutik(model: Model): Dto;
|
||||
|
||||
/** Returns [] for a missing or non-array input. */
|
||||
toHiboutikList(models: Model[]): Dto[] {
|
||||
if (!models || !Array.isArray(models)) return [];
|
||||
|
||||
return models.map((model) => this.toHiboutik(model));
|
||||
}
|
||||
|
||||
/** Returns [] for a missing or non-array input. */
|
||||
fromHiboutikList(hiboutikDtos: Dto[]): Model[] {
|
||||
if (!hiboutikDtos || !Array.isArray(hiboutikDtos)) return [];
|
||||
|
||||
return hiboutikDtos.map((hiboutikDto) => this.fromHiboutik(hiboutikDto));
|
||||
}
|
||||
}
|
||||
44
src/models/converter/customer.converter.spec.ts
Normal file
44
src/models/converter/customer.converter.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CustomerConverter } from './customer.converter';
|
||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto =>
|
||||
({
|
||||
customers_id: 42,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Doe',
|
||||
email: 'jane@example.com',
|
||||
phone: '0600000000',
|
||||
date_of_birth: '1990-05-12',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
...overrides,
|
||||
}) as FullCustomerHiboutikDto;
|
||||
|
||||
describe('CustomerConverter', () => {
|
||||
const converter = new CustomerConverter();
|
||||
|
||||
it('maps a Hiboutik customer', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer())).toEqual({
|
||||
dateOfBirth: new Date('1990-05-12'),
|
||||
email: 'jane@example.com',
|
||||
firstName: 'Jane',
|
||||
hiboutikId: 42,
|
||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||
lastName: 'Doe',
|
||||
phone: '0600000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an invalid date of birth', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the stored email when Hiboutik has none (undefined)', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined();
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('erases the phone when Hiboutik has none (null)', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/models/converter/customer.converter.ts
Normal file
25
src/models/converter/customer.converter.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { CustomerCreateInput } from '../../../prisma/generated/models/Customer';
|
||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerConverter extends Converter<CustomerCreateInput, FullCustomerHiboutikDto> {
|
||||
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
||||
return {
|
||||
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
||||
// undefined (not null) on purpose: an email removed in Hiboutik must not erase the one stored in database
|
||||
email: dto.email?.trim() || undefined,
|
||||
firstName: dto.first_name,
|
||||
hiboutikId: dto.customers_id,
|
||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||
lastName: dto.last_name,
|
||||
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
||||
phone: dto.phone?.trim() || null,
|
||||
};
|
||||
}
|
||||
toHiboutik(): FullCustomerHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
59
src/models/converter/product.converter.spec.ts
Normal file
59
src/models/converter/product.converter.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ProductConverter } from './product.converter';
|
||||
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
|
||||
|
||||
const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHiboutikDto => ({
|
||||
product_id: 1,
|
||||
product_model: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||
product_barcode: '8435497280048',
|
||||
product_brand: 1,
|
||||
product_supplier: 2,
|
||||
product_price: '13.99',
|
||||
product_discount_price: '0.00',
|
||||
product_supply_price: '7.49',
|
||||
product_category: 19,
|
||||
product_display_www: 0,
|
||||
product_arch: 0,
|
||||
products_desc: '',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
product_specific_rules: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('ProductConverter', () => {
|
||||
const converter = new ProductConverter();
|
||||
|
||||
it('maps a Hiboutik product', () => {
|
||||
expect(converter.fromHiboutik(hiboutikProduct())).toEqual({
|
||||
archived: false,
|
||||
barcode: '8435497280048',
|
||||
brand: { connect: { hiboutikId: 1 } },
|
||||
category: { connect: { hiboutikId: 19 } },
|
||||
description: '',
|
||||
discountPrice: null,
|
||||
hiboutikId: 1,
|
||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||
price: '13.99',
|
||||
enabled: false,
|
||||
supplier: { connect: { hiboutikId: 2 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps archived, web display and discount price', () => {
|
||||
const product = converter.fromHiboutik(hiboutikProduct({ product_arch: 1, product_display_www: 1, product_discount_price: '9.99' }));
|
||||
expect(product.archived).toBe(true);
|
||||
expect(product.enabled).toBe(true);
|
||||
expect(product.discountPrice).toBe('9.99');
|
||||
});
|
||||
|
||||
it('stores an empty barcode as null', () => {
|
||||
expect(converter.fromHiboutik(hiboutikProduct({ product_barcode: '' })).barcode).toBeNull();
|
||||
});
|
||||
|
||||
it('does not link missing relations', () => {
|
||||
const product = converter.fromHiboutik(hiboutikProduct({ product_brand: 0, product_category: 0, product_supplier: 0 }));
|
||||
expect(product.brand).toBeUndefined();
|
||||
expect(product.category).toBeUndefined();
|
||||
expect(product.supplier).toBeUndefined();
|
||||
});
|
||||
});
|
||||
31
src/models/converter/product.converter.ts
Normal file
31
src/models/converter/product.converter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
|
||||
import { ProductCreateInput } from '../../../prisma/generated/models/Product';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
||||
|
||||
@Injectable()
|
||||
export class ProductConverter extends Converter<ProductCreateInput, ProductHiboutikDto> {
|
||||
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
||||
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
||||
return {
|
||||
archived: dto.product_arch == 1,
|
||||
barcode: dto.product_barcode || null,
|
||||
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
||||
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
||||
description: dto.products_desc,
|
||||
discountPrice: parseOptionalHiboutikPrice(dto.product_discount_price),
|
||||
hiboutikId: dto.product_id,
|
||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||
name: dto.product_model,
|
||||
price: dto.product_price,
|
||||
enabled: dto.product_display_www == 1,
|
||||
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): ProductHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
19
src/models/converter/supplier.converter.ts
Normal file
19
src/models/converter/supplier.converter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SupplierHiboutikDto } from '../hiboutikDTO/supplier.hiboutik.dto';
|
||||
import { Converter } from './converter';
|
||||
import { SupplierCreateInput } from '../../../prisma/generated/models/Supplier';
|
||||
|
||||
@Injectable()
|
||||
export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHiboutikDto> {
|
||||
fromHiboutik(dto: SupplierHiboutikDto): SupplierCreateInput {
|
||||
return {
|
||||
hiboutikId: dto.supplier_id,
|
||||
name: dto.supplier_name,
|
||||
enabled: dto.supplier_enabled == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): SupplierHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
15
src/utils/date.spec.ts
Normal file
15
src/utils/date.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseHiboutikDate } from './date';
|
||||
|
||||
describe('parseHiboutikDate', () => {
|
||||
it('parses a Hiboutik datetime', () => {
|
||||
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55'));
|
||||
});
|
||||
|
||||
it('parses a date without time', () => {
|
||||
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12'));
|
||||
});
|
||||
|
||||
it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => {
|
||||
expect(parseHiboutikDate(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
10
src/utils/date.ts
Normal file
10
src/utils/date.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD").
|
||||
* Returns undefined for empty or invalid dates (e.g. "0000-00-00").
|
||||
*/
|
||||
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
const date = new Date(value.trim().replace(' ', 'T'));
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
11
src/utils/price.spec.ts
Normal file
11
src/utils/price.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { parseOptionalHiboutikPrice } from './price';
|
||||
|
||||
describe('parseOptionalHiboutikPrice', () => {
|
||||
it('keeps a positive price', () => {
|
||||
expect(parseOptionalHiboutikPrice('12.50')).toBe('12.50');
|
||||
});
|
||||
|
||||
it.each(['0.00', '0', '', 'abc', '-1.00', null, undefined])('returns null for %p', (value) => {
|
||||
expect(parseOptionalHiboutikPrice(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
10
src/utils/price.ts
Normal file
10
src/utils/price.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parses an optional Hiboutik price (discount price, size price override).
|
||||
* Hiboutik sends "0.00" when the value is not set: returns null for empty, zero or invalid values.
|
||||
*/
|
||||
export function parseOptionalHiboutikPrice(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const price = Number(value);
|
||||
return Number.isFinite(price) && price > 0 ? value : null;
|
||||
}
|
||||
Reference in New Issue
Block a user