diff --git a/prisma/customObjects/prisma.custom.object.ts b/prisma/customObjects/prisma.custom.object.ts index 2574315..0bdb5e4 100644 --- a/prisma/customObjects/prisma.custom.object.ts +++ b/prisma/customObjects/prisma.custom.object.ts @@ -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 & { + lines: Prisma.OrderLineCreateWithoutOrderInput[]; +}; diff --git a/src/app.module.ts b/src/app.module.ts index 84bca7f..db86dc2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/database/order.database.repository.ts b/src/database/order.database.repository.ts new file mode 100644 index 0000000..3222cc0 --- /dev/null +++ b/src/database/order.database.repository.ts @@ -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> { + 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 { + 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 { + 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; + }); + } +} diff --git a/src/hiboutik/sale.hiboutik.repository.ts b/src/hiboutik/sale.hiboutik.repository.ts new file mode 100644 index 0000000..993a8c3 --- /dev/null +++ b/src/hiboutik/sale.hiboutik.repository.ts @@ -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 { + const data = await this.hiboutikClient.get('/sales/' + String(id)); + const sale = data[0]; + if (!sale) throw new Error(`Hiboutik sale ${id} not found`); + return sale; + } +} diff --git a/src/models/converter/order.converter.spec.ts b/src/models/converter/order.converter.spec.ts new file mode 100644 index 0000000..c38bfcf --- /dev/null +++ b/src/models/converter/order.converter.spec.ts @@ -0,0 +1,80 @@ +import { OrderConverter } from './order.converter'; +import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto'; + +const hiboutikLine = (overrides: Partial = {}): 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 => + ({ + 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([]); + }); +}); diff --git a/src/models/converter/order.converter.ts b/src/models/converter/order.converter.ts new file mode 100644 index 0000000..9d8e3af --- /dev/null +++ b/src/models/converter/order.converter.ts @@ -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 { + /** + * 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.'); + } +} diff --git a/src/models/hiboutikDTO/sale.hiboutik.dto.ts b/src/models/hiboutikDTO/sale.hiboutik.dto.ts new file mode 100644 index 0000000..4d3486c --- /dev/null +++ b/src/models/hiboutikDTO/sale.hiboutik.dto.ts @@ -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; +} diff --git a/src/models/orderStatus.ts b/src/models/orderStatus.ts new file mode 100644 index 0000000..e23e101 --- /dev/null +++ b/src/models/orderStatus.ts @@ -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]; diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index afb09ad..bf7115b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -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(); + }); + }); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 6e5cdf0..996c09b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -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 { + 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}`, + ); + } }