feat(sync): synchronize customer adresses and orders from Hiboutik sales (#1)
This commit is contained in:
@@ -2,3 +2,11 @@ import { Prisma } from '../generated/client';
|
||||
|
||||
/** Product loaded with its versions (`include: { versions: true }`). */
|
||||
export type ProductWithVersions = Prisma.ProductGetPayload<{ include: { versions: true } }>;
|
||||
|
||||
/**
|
||||
* Order to save with its lines. Lines are kept apart from the nested Prisma input so that the repository can link
|
||||
* them to the current catalog (`productVersion`) and replace them on update.
|
||||
*/
|
||||
export type OrderWithLinesInput = Omit<Prisma.OrderCreateInput, 'lines'> & {
|
||||
lines: Prisma.OrderLineCreateWithoutOrderInput[];
|
||||
};
|
||||
|
||||
@@ -146,16 +146,29 @@ model Customer {
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
/// Address of a customer, mirrored from Hiboutik (sync not implemented yet).
|
||||
/// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
|
||||
model CustomerAddress {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
address String
|
||||
city String
|
||||
company String?
|
||||
/// Company registration number (SIRET...), only for an address with a company
|
||||
companyNumber String?
|
||||
country String
|
||||
firstName String
|
||||
hiboutikId Int @unique
|
||||
isDefault Boolean @default(false)
|
||||
lastName String
|
||||
/// Company legal status (SAS, SARL...), only for an address with a company
|
||||
legalStatus String?
|
||||
/// Free complement (building, floor, door code...)
|
||||
other String?
|
||||
phone String?
|
||||
state String?
|
||||
/// Company VAT number, only for an address with a company
|
||||
taxNumber String?
|
||||
zipCode String
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -22,6 +22,11 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data
|
||||
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
||||
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
||||
import { CustomerConverter } from './models/converter/customer.converter';
|
||||
import { OrderConverter } from './models/converter/order.converter';
|
||||
import { OrderDatabaseRepository } from './database/order.database.repository';
|
||||
import { SaleHiboutikRepository } from './hiboutik/sale.hiboutik.repository';
|
||||
import { CustomerAddressConverter } from './models/converter/customerAddress.converter';
|
||||
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
|
||||
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
||||
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
||||
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
||||
@@ -46,17 +51,22 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
|
||||
CategoryDatabaseRepository,
|
||||
CategoryHiboutikRepository,
|
||||
CategoryHiboutikService,
|
||||
CustomerAddressConverter,
|
||||
CustomerAddressDatabaseRepository,
|
||||
CustomerHiboutikRepository,
|
||||
CustomerDatabaseRepository,
|
||||
CustomerConverter,
|
||||
CustomerHiboutikService,
|
||||
HiboutikClient,
|
||||
HiboutikService,
|
||||
OrderConverter,
|
||||
OrderDatabaseRepository,
|
||||
ProductConverter,
|
||||
ProductDatabaseRepository,
|
||||
ProductHiboutikRepository,
|
||||
ProductHiboutikService,
|
||||
ProductVersionDatabaseRepository,
|
||||
SaleHiboutikRepository,
|
||||
StockHiboutikRepository,
|
||||
SupplierConverter,
|
||||
SupplierDatabaseRepository,
|
||||
|
||||
27
src/database/customerAddress.database.repository.ts
Normal file
27
src/database/customerAddress.database.repository.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CustomerAddress } from '../../prisma/generated/client';
|
||||
import { CustomerAddressCreateInput } from '../../prisma/generated/models/CustomerAddress';
|
||||
|
||||
/** Database access to customer addresses. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class CustomerAddressDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Creates or updates the address with the same `hiboutikId`. */
|
||||
async upsert(address: CustomerAddressCreateInput): Promise<CustomerAddress> {
|
||||
return this.prisma.customerAddress.upsert({
|
||||
where: { hiboutikId: address.hiboutikId },
|
||||
create: address,
|
||||
update: address,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the addresses of a customer that no longer exist in Hiboutik (all of them if `keptHiboutikIds` is empty). */
|
||||
async deleteMissing(customerId: string, keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.customerAddress.deleteMany({
|
||||
where: { customerId, hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
61
src/database/order.database.repository.ts
Normal file
61
src/database/order.database.repository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Order } from '../../prisma/generated/client';
|
||||
import { OrderWithLinesInput } from '../../prisma/customObjects/prisma.custom.object';
|
||||
import { OrderStatus } from '../models/orderStatus';
|
||||
|
||||
/** Database access to orders. Hiboutik sales are matched by `hiboutikSaleId`. */
|
||||
@Injectable()
|
||||
export class OrderDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Among `hiboutikSaleIds`, the ones already stored as completed orders. */
|
||||
async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise<Set<number>> {
|
||||
if (hiboutikSaleIds.length === 0) return new Set();
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED },
|
||||
select: { hiboutikSaleId: true },
|
||||
});
|
||||
return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the order with the same `hiboutikSaleId`, replacing all its lines (atomically).
|
||||
* Each line is linked to the current product version matching its Hiboutik product and size, if it still exists.
|
||||
* On update, `source` is kept: a website order sent to Hiboutik stays WEB.
|
||||
*/
|
||||
async upsert(order: OrderWithLinesInput): Promise<Order> {
|
||||
if (order.hiboutikSaleId == null) throw new Error('Cannot upsert an order without hiboutikSaleId');
|
||||
|
||||
const { lines, source, ...orderFields } = order;
|
||||
const linkedLines = await this.linkToProductVersions(lines);
|
||||
return this.prisma.order.upsert({
|
||||
where: { hiboutikSaleId: order.hiboutikSaleId },
|
||||
create: { ...orderFields, source, lines: { create: linkedLines } },
|
||||
update: {
|
||||
...orderFields,
|
||||
// an undefined relation is ignored by Prisma on update: explicitly unlink a customer removed from the sale
|
||||
customer: orderFields.customer ?? { disconnect: true },
|
||||
lines: { deleteMany: {}, create: linkedLines },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async linkToProductVersions(lines: OrderWithLinesInput['lines']): Promise<OrderWithLinesInput['lines']> {
|
||||
if (lines.length === 0) return lines;
|
||||
|
||||
const versions = await this.prisma.productVersion.findMany({
|
||||
where: {
|
||||
OR: lines.map((line) => ({ hiboutikSizeId: line.hiboutikSizeId, product: { hiboutikId: line.hiboutikProductId } })),
|
||||
},
|
||||
select: { id: true, hiboutikSizeId: true, product: { select: { hiboutikId: true } } },
|
||||
});
|
||||
const versionIds = new Map(versions.map((version) => [`${version.product.hiboutikId}:${version.hiboutikSizeId}`, version.id]));
|
||||
|
||||
return lines.map((line) => {
|
||||
const versionId = versionIds.get(`${line.hiboutikProductId}:${line.hiboutikSizeId}`);
|
||||
return versionId ? { ...line, productVersion: { connect: { id: versionId } } } : line;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { CustomerHiboutikDto, FullCustomerHiboutikDto } from '../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
import {
|
||||
CustomerAddressDetailsHiboutikDto,
|
||||
CustomerHiboutikDto,
|
||||
FullCustomerHiboutikDto,
|
||||
} from '../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
/** Reads customers from the Hiboutik API. */
|
||||
@Injectable()
|
||||
@@ -22,4 +26,15 @@ export class CustomerHiboutikRepository {
|
||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address with its company details (Hiboutik answers with a one-element array).
|
||||
* @throws if the address does not exist.
|
||||
*/
|
||||
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
||||
const address = data[0];
|
||||
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/hiboutik/sale.hiboutik.repository.ts
Normal file
20
src/hiboutik/sale.hiboutik.repository.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
||||
|
||||
/** Reads sales from the Hiboutik API. */
|
||||
@Injectable()
|
||||
export class SaleHiboutikRepository {
|
||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||
|
||||
/**
|
||||
* Full sale, with its lines (Hiboutik answers with a one-element array).
|
||||
* @throws if the sale does not exist.
|
||||
*/
|
||||
async getSaleById(id: number): Promise<SaleHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<SaleHiboutikDto[]>('/sales/' + String(id));
|
||||
const sale = data[0];
|
||||
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
||||
return sale;
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,18 @@ export interface CustomerAddressHiboutikDto {
|
||||
other: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto {
|
||||
tax_number: string;
|
||||
company_number: string;
|
||||
legal_status: string;
|
||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */
|
||||
export interface CustomerSaleHiboutikDto {
|
||||
sale_id: number;
|
||||
|
||||
40
src/models/hiboutikDTO/sale.hiboutik.dto.ts
Normal file
40
src/models/hiboutikDTO/sale.hiboutik.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines.
|
||||
* Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts,
|
||||
* totals by tax, customer and store details...).
|
||||
*/
|
||||
export interface SaleHiboutikDto {
|
||||
sale_id: number;
|
||||
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
||||
created_at: string;
|
||||
/** Local time, "0000-00-00 00:00:00" while the sale is not completed. */
|
||||
completed_at: string;
|
||||
store_id: number;
|
||||
customer_id: number;
|
||||
currency: string;
|
||||
/** Decimal as a string, taxes included. */
|
||||
total: string;
|
||||
/** Payment type code, e.g. "CB", "ESP", "WEB". */
|
||||
payment: string;
|
||||
/** 1 for a quotation. */
|
||||
quotation: number;
|
||||
line_items: SaleLineHiboutikDto[];
|
||||
}
|
||||
|
||||
/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */
|
||||
export interface SaleLineHiboutikDto {
|
||||
line_item_id: number;
|
||||
product_id: number;
|
||||
/** Hiboutik size id, 0 for a product without sizes. */
|
||||
product_size: number;
|
||||
/** Negative for a returned item. */
|
||||
quantity: number;
|
||||
product_model: string;
|
||||
product_barcode: string;
|
||||
/** Size name, empty for a product without sizes. */
|
||||
size_name: string;
|
||||
/** Unit price before discount, taxes included (decimal as a string). */
|
||||
item_unit_gross_wo_discount: string;
|
||||
/** Unit price actually paid, discount deducted, taxes included (decimal as a string). */
|
||||
item_unit_gross: string;
|
||||
}
|
||||
9
src/models/orderStatus.ts
Normal file
9
src/models/orderStatus.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Values of `Order.status`. */
|
||||
export const OrderStatus = {
|
||||
/** Hiboutik sale not completed (cart in progress, awaiting payment...). */
|
||||
PENDING: 'PENDING',
|
||||
/** Hiboutik sale completed: it no longer changes, so the sync does not fetch it again. */
|
||||
COMPLETED: 'COMPLETED',
|
||||
} as const;
|
||||
|
||||
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
|
||||
@@ -2,9 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||
|
||||
describe('CustomerHiboutikService', () => {
|
||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: jest.fn() };
|
||||
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
const customerConverter = { fromHiboutik: jest.fn() };
|
||||
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
const saleHiboutikRepository = { getSaleById: jest.fn() };
|
||||
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
|
||||
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
let service: CustomerHiboutikService;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -16,12 +21,21 @@ describe('CustomerHiboutikService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
||||
service = new CustomerHiboutikService(
|
||||
customerHiboutikRepository as never,
|
||||
customerDatabaseRepository as never,
|
||||
customerConverter as never,
|
||||
customerAddressDatabaseRepository as never,
|
||||
customerAddressConverter as never,
|
||||
saleHiboutikRepository as never,
|
||||
orderDatabaseRepository as never,
|
||||
orderConverter as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -48,4 +62,145 @@ describe('CustomerHiboutikService', () => {
|
||||
|
||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('addresses', () => {
|
||||
beforeEach(() => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||
});
|
||||
|
||||
it('deletes the missing addresses of the customer, then upserts the others linked to it', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [
|
||||
{ address_id: 10, customers_id: 1 },
|
||||
{ address_id: '11', customers_id: 999 },
|
||||
],
|
||||
});
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 });
|
||||
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes all the addresses of a customer that has none left', async () => {
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', []);
|
||||
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the stored addresses when the payload has no addresses array', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1 });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches the company details of addresses with a company only', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [
|
||||
{ address_id: 10, company: 'ACME' },
|
||||
{ address_id: 11, company: ' ' },
|
||||
],
|
||||
});
|
||||
customerHiboutikRepository.getAddressById.mockResolvedValue({
|
||||
address_id: 10,
|
||||
tax_number: 'FR123',
|
||||
company_number: '123',
|
||||
legal_status: 'SAS',
|
||||
updated_at: '2026-11-01 10:00:00',
|
||||
});
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledTimes(1);
|
||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledWith(10);
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({
|
||||
address_id: 10,
|
||||
company: 'ACME',
|
||||
customers_id: 1,
|
||||
tax_number: 'FR123',
|
||||
company_number: '123',
|
||||
legal_status: 'SAS',
|
||||
updated_at: '2026-11-01 10:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('still syncs an address whose company details cannot be fetched', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [{ address_id: 10, company: 'ACME' }] });
|
||||
customerHiboutikRepository.getAddressById.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ address_id: 10, company: 'ACME', customers_id: 1 });
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps syncing the other addresses when one fails', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [{ address_id: 10 }, { address_id: 11 }],
|
||||
});
|
||||
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orders', () => {
|
||||
beforeEach(() => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [],
|
||||
sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches and upserts the sales not yet stored as completed, linked to the customer', async () => {
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set([6]));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]);
|
||||
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
||||
});
|
||||
|
||||
it('ignores quotations', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockResolvedValueOnce({ sale_id: 5, quotation: 1 });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps syncing the other sales when one fails', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips orders when the payload has no sales array', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(saleHiboutikRepository.getSaleById).not.toHaveBeenCalled();
|
||||
expect(orderDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,17 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
|
||||
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
||||
import { CustomerConverter } from '../../models/converter/customer.converter';
|
||||
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
|
||||
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
|
||||
import {
|
||||
CustomerAddressDetailsHiboutikDto,
|
||||
CustomerAddressHiboutikDto,
|
||||
FullCustomerHiboutikDto,
|
||||
} from '../../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository';
|
||||
import { OrderDatabaseRepository } from '../../database/order.database.repository';
|
||||
import { OrderConverter } from '../../models/converter/order.converter';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerHiboutikService {
|
||||
@@ -11,11 +22,17 @@ export class CustomerHiboutikService {
|
||||
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||
private readonly customerConverter: CustomerConverter,
|
||||
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
|
||||
private readonly customerAddressConverter: CustomerAddressConverter,
|
||||
private readonly saleHiboutikRepository: SaleHiboutikRepository,
|
||||
private readonly orderDatabaseRepository: OrderDatabaseRepository,
|
||||
private readonly orderConverter: OrderConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
||||
* (one API call per customer, as the list endpoint lacks addresses and sales).
|
||||
* with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one
|
||||
* per sale not yet stored as completed).
|
||||
*/
|
||||
async syncCustomers() {
|
||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||
@@ -29,21 +46,105 @@ export class CustomerHiboutikService {
|
||||
}
|
||||
|
||||
let nbFailed = 0;
|
||||
let nbFailedAddresses = 0;
|
||||
let nbFailedOrders = 0;
|
||||
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||
try {
|
||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
// retreive addresses DTOs
|
||||
// save addresses
|
||||
// retreive orders DTOs
|
||||
// save orders
|
||||
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
||||
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
||||
} catch (error) {
|
||||
nbFailed++;
|
||||
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @returns the number of failed addresses.
|
||||
*/
|
||||
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
|
||||
if (!Array.isArray(hiboutikCustomer.addresses)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await this.customerAddressDatabaseRepository.deleteMissing(
|
||||
customerId,
|
||||
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
|
||||
);
|
||||
return forEachSafely(
|
||||
hiboutikCustomer.addresses,
|
||||
// Linked to the parent customer, whatever the embedded customers_id says
|
||||
async (dto) => {
|
||||
const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto;
|
||||
await this.customerAddressDatabaseRepository.upsert(
|
||||
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes an embedded address with its company details (one API call). On failure, the embedded address is returned
|
||||
* as is, so the address is still synced and its stored company details are kept.
|
||||
*/
|
||||
private async withCompanyDetails(
|
||||
dto: CustomerAddressHiboutikDto,
|
||||
): Promise<CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto> {
|
||||
try {
|
||||
const details = await this.customerHiboutikRepository.getAddressById(dto.address_id);
|
||||
return {
|
||||
...dto,
|
||||
tax_number: details.tax_number,
|
||||
company_number: details.company_number,
|
||||
legal_status: details.legal_status,
|
||||
updated_at: details.updated_at,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the sales of a full Hiboutik customer as orders: each sale not yet stored as completed is fetched (for its
|
||||
* lines) and upserted (a failing sale is logged and skipped). Quotations are ignored. Orders are never deleted.
|
||||
* @returns the number of failed sales.
|
||||
*/
|
||||
private async syncOrders(hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
if (!Array.isArray(hiboutikCustomer.sales)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no sales array: orders sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
||||
hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)),
|
||||
);
|
||||
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id)));
|
||||
|
||||
return forEachSafely(
|
||||
salesToSync,
|
||||
async (dto) => {
|
||||
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
||||
if (Number(sale.quotation) === 1) return;
|
||||
// Linked to the parent customer, whatever the sale says
|
||||
await this.orderDatabaseRepository.upsert(
|
||||
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik sale ${dto.sale_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user