feat(sync): synchronize customer orders from Hiboutik sales
For each customer sale not yet stored as completed, fetch the full sale (GET /sales/:id) and upsert it as a STORE order keyed on hiboutikSaleId. Lines are replaced on each upsert and linked to the matching product version when it still exists. Quotations are skipped, orders are never deleted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data
|
||||
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
||||
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
||||
import { CustomerConverter } from './models/converter/customer.converter';
|
||||
import { OrderConverter } from './models/converter/order.converter';
|
||||
import { OrderDatabaseRepository } from './database/order.database.repository';
|
||||
import { SaleHiboutikRepository } from './hiboutik/sale.hiboutik.repository';
|
||||
import { CustomerAddressConverter } from './models/converter/customerAddress.converter';
|
||||
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
|
||||
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
||||
@@ -56,11 +59,14 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
|
||||
CustomerHiboutikService,
|
||||
HiboutikClient,
|
||||
HiboutikService,
|
||||
OrderConverter,
|
||||
OrderDatabaseRepository,
|
||||
ProductConverter,
|
||||
ProductDatabaseRepository,
|
||||
ProductHiboutikRepository,
|
||||
ProductHiboutikService,
|
||||
ProductVersionDatabaseRepository,
|
||||
SaleHiboutikRepository,
|
||||
StockHiboutikRepository,
|
||||
SupplierConverter,
|
||||
SupplierDatabaseRepository,
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
}
|
||||
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];
|
||||
@@ -7,6 +7,9 @@ describe('CustomerHiboutikService', () => {
|
||||
const customerConverter = { fromHiboutik: jest.fn() };
|
||||
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
const saleHiboutikRepository = { getSaleById: jest.fn() };
|
||||
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
|
||||
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
let service: CustomerHiboutikService;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -22,12 +25,17 @@ describe('CustomerHiboutikService', () => {
|
||||
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
||||
service = new CustomerHiboutikService(
|
||||
customerHiboutikRepository as never,
|
||||
customerDatabaseRepository as never,
|
||||
customerConverter as never,
|
||||
customerAddressDatabaseRepository as never,
|
||||
customerAddressConverter as never,
|
||||
saleHiboutikRepository as never,
|
||||
orderDatabaseRepository as never,
|
||||
orderConverter as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,4 +156,51 @@ describe('CustomerHiboutikService', () => {
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orders', () => {
|
||||
beforeEach(() => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [],
|
||||
sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches and upserts the sales not yet stored as completed, linked to the customer', async () => {
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set([6]));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]);
|
||||
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
||||
});
|
||||
|
||||
it('ignores quotations', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockResolvedValueOnce({ sale_id: 5, quotation: 1 });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps syncing the other sales when one fails', async () => {
|
||||
saleHiboutikRepository.getSaleById.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips orders when the payload has no sales array', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(saleHiboutikRepository.getSaleById).not.toHaveBeenCalled();
|
||||
expect(orderDatabaseRepository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
FullCustomerHiboutikDto,
|
||||
} from '../../models/hiboutikDTO/customer.hiboutik.dto';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository';
|
||||
import { OrderDatabaseRepository } from '../../database/order.database.repository';
|
||||
import { OrderConverter } from '../../models/converter/order.converter';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerHiboutikService {
|
||||
@@ -21,11 +24,15 @@ export class CustomerHiboutikService {
|
||||
private readonly customerConverter: CustomerConverter,
|
||||
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
|
||||
private readonly customerAddressConverter: CustomerAddressConverter,
|
||||
private readonly saleHiboutikRepository: SaleHiboutikRepository,
|
||||
private readonly orderDatabaseRepository: OrderDatabaseRepository,
|
||||
private readonly orderConverter: OrderConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
||||
* with its addresses (one API call per customer, as the list endpoint lacks addresses and sales).
|
||||
* with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one
|
||||
* per sale not yet stored as completed).
|
||||
*/
|
||||
async syncCustomers() {
|
||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
||||
@@ -40,20 +47,20 @@ export class CustomerHiboutikService {
|
||||
|
||||
let nbFailed = 0;
|
||||
let nbFailedAddresses = 0;
|
||||
let nbFailedOrders = 0;
|
||||
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||
try {
|
||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
||||
// retreive orders DTOs
|
||||
// save orders
|
||||
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
||||
} catch (error) {
|
||||
nbFailed++;
|
||||
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed.`,
|
||||
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,4 +115,36 @@ export class CustomerHiboutikService {
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the sales of a full Hiboutik customer as orders: each sale not yet stored as completed is fetched (for its
|
||||
* lines) and upserted (a failing sale is logged and skipped). Quotations are ignored. Orders are never deleted.
|
||||
* @returns the number of failed sales.
|
||||
*/
|
||||
private async syncOrders(hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
if (!Array.isArray(hiboutikCustomer.sales)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no sales array: orders sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
||||
hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)),
|
||||
);
|
||||
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id)));
|
||||
|
||||
return forEachSafely(
|
||||
salesToSync,
|
||||
async (dto) => {
|
||||
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
||||
if (Number(sale.quotation) === 1) return;
|
||||
// Linked to the parent customer, whatever the sale says
|
||||
await this.orderDatabaseRepository.upsert(
|
||||
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik sale ${dto.sale_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user