feat(sync): synchronize customer adresses and orders from Hiboutik sales (#1)
Co-authored-by: MastersGame <quentin.duwa1210@gmail.com> Reviewed-on: #1
This commit is contained in:
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { CustomerAddressConverter } from './customerAddress.converter';
|
||||
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto =>
|
||||
({
|
||||
address_id: 7,
|
||||
customers_id: 42,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Doe',
|
||||
company: 'ACME',
|
||||
phone: '0600000000',
|
||||
address: ' 1 rue de la Paix ',
|
||||
zip_code: '75002',
|
||||
city: 'Paris',
|
||||
state: '',
|
||||
country: 'FRA',
|
||||
other: 'Bât. B',
|
||||
default: 1,
|
||||
...overrides,
|
||||
}) as CustomerAddressHiboutikDto;
|
||||
|
||||
describe('CustomerAddressConverter', () => {
|
||||
const converter = new CustomerAddressConverter();
|
||||
|
||||
it('maps a Hiboutik address', () => {
|
||||
expect(converter.fromHiboutik(hiboutikAddress())).toEqual({
|
||||
address: '1 rue de la Paix',
|
||||
city: 'Paris',
|
||||
company: 'ACME',
|
||||
country: 'FRA',
|
||||
firstName: 'Jane',
|
||||
hiboutikId: 7,
|
||||
isDefault: true,
|
||||
lastName: 'Doe',
|
||||
other: 'Bât. B',
|
||||
phone: '0600000000',
|
||||
state: null,
|
||||
zipCode: '75002',
|
||||
customer: { connect: { hiboutikId: 42 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts ids and flags sent as strings', () => {
|
||||
const address = converter.fromHiboutik(hiboutikAddress({ address_id: '8', customers_id: '43', default: '0' } as never));
|
||||
expect(address.hiboutikId).toBe(8);
|
||||
expect(address.isDefault).toBe(false);
|
||||
expect(address.customer).toEqual({ connect: { hiboutikId: 43 } });
|
||||
});
|
||||
|
||||
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
|
||||
const address = converter.fromHiboutik(
|
||||
hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }),
|
||||
);
|
||||
expect(address.company).toBeNull();
|
||||
expect(address.phone).toBeNull();
|
||||
expect(address.other).toBeNull();
|
||||
expect(address.firstName).toBe('');
|
||||
});
|
||||
|
||||
describe('company details', () => {
|
||||
const details = { tax_number: ' FR123 ', company_number: '12345678900012', legal_status: 'SAS', updated_at: '2026-11-01 10:00:00' };
|
||||
|
||||
it('maps them when fetched for an address with a company', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details });
|
||||
expect(address.taxNumber).toBe('FR123');
|
||||
expect(address.companyNumber).toBe('12345678900012');
|
||||
expect(address.legalStatus).toBe('SAS');
|
||||
});
|
||||
|
||||
it('erases empty fetched values (null)', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details, tax_number: '' });
|
||||
expect(address.taxNumber).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the stored ones when not fetched (undefined)', () => {
|
||||
const address = converter.fromHiboutik(hiboutikAddress());
|
||||
expect(address.taxNumber).toBeUndefined();
|
||||
expect(address.companyNumber).toBeUndefined();
|
||||
expect(address.legalStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('erases them when the address has no company (null)', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress({ company: '' }), ...details });
|
||||
expect(address.taxNumber).toBeNull();
|
||||
expect(address.companyNumber).toBeNull();
|
||||
expect(address.legalStatus).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/models/converter/customerAddress.converter.ts
Normal file
49
src/models/converter/customerAddress.converter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
||||
import { CustomerAddressDetailsHiboutikDto, CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
/** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */
|
||||
const optional = (value: string | null | undefined): string | null => value?.trim() || null;
|
||||
const required = (value: string | null | undefined): string => value?.trim() ?? '';
|
||||
|
||||
/**
|
||||
* Maps an address. Company details (tax/company number, legal status) are:
|
||||
* - `null` when the address has no company,
|
||||
* - mapped when `dto` comes from `GET /customers_addresses/:id` ({@link CustomerAddressDetailsHiboutikDto}),
|
||||
* - `undefined` (stored values kept) when the details were not fetched.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerAddressConverter extends Converter<
|
||||
CustomerAddressCreateInput,
|
||||
CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto
|
||||
> {
|
||||
fromHiboutik(dto: CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto): CustomerAddressCreateInput {
|
||||
const company = optional(dto.company);
|
||||
const hasDetails = 'tax_number' in dto;
|
||||
const companyDetail = (value: string | null | undefined) => (!company ? null : hasDetails ? optional(value) : undefined);
|
||||
const details = dto as Partial<CustomerAddressDetailsHiboutikDto>;
|
||||
|
||||
return {
|
||||
address: required(dto.address),
|
||||
city: required(dto.city),
|
||||
company,
|
||||
companyNumber: companyDetail(details.company_number),
|
||||
country: required(dto.country),
|
||||
firstName: required(dto.first_name),
|
||||
hiboutikId: Number(dto.address_id),
|
||||
isDefault: Number(dto.default) === 1,
|
||||
lastName: required(dto.last_name),
|
||||
legalStatus: companyDetail(details.legal_status),
|
||||
other: optional(dto.other),
|
||||
phone: optional(dto.phone),
|
||||
state: optional(dto.state),
|
||||
taxNumber: companyDetail(details.tax_number),
|
||||
zipCode: required(dto.zip_code),
|
||||
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
||||
};
|
||||
}
|
||||
toHiboutik(): CustomerAddressHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
80
src/models/converter/order.converter.spec.ts
Normal file
80
src/models/converter/order.converter.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { OrderConverter } from './order.converter';
|
||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||
|
||||
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
|
||||
line_item_id: 3,
|
||||
product_id: 746,
|
||||
product_size: 1,
|
||||
quantity: 2,
|
||||
product_model: 'KATSEYE - WILD',
|
||||
product_barcode: '2430010007467',
|
||||
size_name: 'Daniela',
|
||||
item_unit_gross_wo_discount: '19.00',
|
||||
item_unit_gross: '17.10',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto =>
|
||||
({
|
||||
sale_id: 5,
|
||||
created_at: '2026-10-18 11:00:21',
|
||||
completed_at: '2026-10-18 11:01:04',
|
||||
customer_id: 1,
|
||||
total: '34.20',
|
||||
quotation: 0,
|
||||
line_items: [hiboutikLine()],
|
||||
...overrides,
|
||||
}) as SaleHiboutikDto;
|
||||
|
||||
describe('OrderConverter', () => {
|
||||
const converter = new OrderConverter();
|
||||
|
||||
it('maps a completed Hiboutik sale with its lines', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
|
||||
hiboutikSaleId: 5,
|
||||
orderedAt: new Date('2026-10-18T11:01:04'),
|
||||
source: 'STORE',
|
||||
status: 'COMPLETED',
|
||||
total: '34.20',
|
||||
customer: { connect: { hiboutikId: 1 } },
|
||||
lines: [
|
||||
{
|
||||
barcode: '2430010007467',
|
||||
hiboutikProductId: 746,
|
||||
hiboutikSizeId: 1,
|
||||
productName: 'KATSEYE - WILD',
|
||||
quantity: 2,
|
||||
unitPrice: '17.10',
|
||||
versionName: 'Daniela',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a sale not completed yet as PENDING, ordered at its creation date', () => {
|
||||
const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' }));
|
||||
expect(order.status).toBe('PENDING');
|
||||
expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21'));
|
||||
});
|
||||
|
||||
it('throws when the sale has no valid date', () => {
|
||||
expect(() => converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00', created_at: '' }))).toThrow();
|
||||
});
|
||||
|
||||
it('leaves an anonymous sale without customer', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale({ customer_id: 0 })).customer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the product name as version name and null barcode for a product without sizes', () => {
|
||||
const [line] = converter.fromHiboutik(
|
||||
hiboutikSale({ line_items: [hiboutikLine({ product_size: 0, size_name: '', product_barcode: '' })] }),
|
||||
).lines;
|
||||
expect(line.versionName).toBe('KATSEYE - WILD');
|
||||
expect(line.barcode).toBeNull();
|
||||
expect(line.hiboutikSizeId).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts a sale without lines', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
49
src/models/converter/order.converter.ts
Normal file
49
src/models/converter/order.converter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { OrderSource } from '../../../prisma/generated/client';
|
||||
import { OrderWithLinesInput } from '../../../prisma/customObjects/prisma.custom.object';
|
||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
import { OrderStatus } from '../orderStatus';
|
||||
|
||||
@Injectable()
|
||||
export class OrderConverter extends Converter<OrderWithLinesInput, SaleHiboutikDto> {
|
||||
/**
|
||||
* Maps a Hiboutik sale to an order (source STORE) with a snapshot of its lines.
|
||||
* @throws if the sale has neither a valid completion date nor a valid creation date.
|
||||
*/
|
||||
fromHiboutik(dto: SaleHiboutikDto): OrderWithLinesInput {
|
||||
const completedAt = parseHiboutikDate(dto.completed_at);
|
||||
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
|
||||
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
|
||||
|
||||
const customerId = Number(dto.customer_id);
|
||||
return {
|
||||
hiboutikSaleId: Number(dto.sale_id),
|
||||
orderedAt,
|
||||
source: OrderSource.STORE,
|
||||
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
|
||||
total: dto.total,
|
||||
// 0 for an anonymous sale
|
||||
customer: customerId > 0 ? { connect: { hiboutikId: customerId } } : undefined,
|
||||
lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)),
|
||||
};
|
||||
}
|
||||
|
||||
private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] {
|
||||
return {
|
||||
barcode: line.product_barcode?.trim() || null,
|
||||
hiboutikProductId: Number(line.product_id),
|
||||
hiboutikSizeId: Number(line.product_size),
|
||||
productName: line.product_model,
|
||||
quantity: Number(line.quantity),
|
||||
unitPrice: line.item_unit_gross,
|
||||
// same rule as ProductVersion: the product name for a product without sizes
|
||||
versionName: line.size_name?.trim() || line.product_model,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): SaleHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user