From 9dddf7562a9671f63c1330b73cf90f669bde20a5 Mon Sep 17 00:00:00 2001 From: quentin_duwa Date: Sat, 7 Nov 2026 09:56:53 +0000 Subject: [PATCH] feat(sync): synchronize customer adresses and orders from Hiboutik sales (#1) Co-authored-by: MastersGame Reviewed-on: https://git.quentinduwa.fr/quentin_duwa/dinggadingga-backend/pulls/1 --- prisma/customObjects/prisma.custom.object.ts | 8 + prisma/schema.prisma | 27 ++- src/app.module.ts | 10 ++ .../customerAddress.database.repository.ts | 27 +++ src/database/order.database.repository.ts | 61 +++++++ src/hiboutik/customer.hiboutik.repository.ts | 17 +- src/hiboutik/sale.hiboutik.repository.ts | 20 +++ .../customerAddress.converter.spec.ts | 89 ++++++++++ .../converter/customerAddress.converter.ts | 49 ++++++ src/models/converter/order.converter.spec.ts | 80 +++++++++ src/models/converter/order.converter.ts | 49 ++++++ .../hiboutikDTO/customer.hiboutik.dto.ts | 12 ++ src/models/hiboutikDTO/sale.hiboutik.dto.ts | 40 +++++ src/models/orderStatus.ts | 9 + .../customer.hiboutik.service.spec.ts | 159 +++++++++++++++++- .../hiboutik/customer.hiboutik.service.ts | 115 ++++++++++++- 16 files changed, 755 insertions(+), 17 deletions(-) create mode 100644 src/database/customerAddress.database.repository.ts create mode 100644 src/database/order.database.repository.ts create mode 100644 src/hiboutik/sale.hiboutik.repository.ts create mode 100644 src/models/converter/customerAddress.converter.spec.ts create mode 100644 src/models/converter/customerAddress.converter.ts create mode 100644 src/models/converter/order.converter.spec.ts create mode 100644 src/models/converter/order.converter.ts create mode 100644 src/models/hiboutikDTO/sale.hiboutik.dto.ts create mode 100644 src/models/orderStatus.ts 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/prisma/schema.prisma b/prisma/schema.prisma index a682abd..922e85e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,17 +146,30 @@ 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 - country String - hiboutikId Int @unique - isDefault Boolean @default(false) - zipCode String + 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) customerId String diff --git a/src/app.module.ts b/src/app.module.ts index bb44aee..db86dc2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/database/customerAddress.database.repository.ts b/src/database/customerAddress.database.repository.ts new file mode 100644 index 0000000..c5308fc --- /dev/null +++ b/src/database/customerAddress.database.repository.ts @@ -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 { + 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 { + const result = await this.prisma.customerAddress.deleteMany({ + where: { customerId, hiboutikId: { notIn: keptHiboutikIds } }, + }); + return result.count; + } +} 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/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts index 95d0dc3..50179dd 100644 --- a/src/hiboutik/customer.hiboutik.repository.ts +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -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 { + const data = await this.hiboutikClient.get('/customers_addresses/' + String(id)); + const address = data[0]; + if (!address) throw new Error(`Hiboutik address ${id} not found`); + return address; + } } 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/customerAddress.converter.spec.ts b/src/models/converter/customerAddress.converter.spec.ts new file mode 100644 index 0000000..eee113f --- /dev/null +++ b/src/models/converter/customerAddress.converter.spec.ts @@ -0,0 +1,89 @@ +import { CustomerAddressConverter } from './customerAddress.converter'; +import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; + +const hiboutikAddress = (overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts new file mode 100644 index 0000000..ab50c37 --- /dev/null +++ b/src/models/converter/customerAddress.converter.ts @@ -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; + + 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.'); + } +} 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/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts index f945d69..ccb42b6 100644 --- a/src/models/hiboutikDTO/customer.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -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; 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 b15648a..bf7115b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -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(); + }); + }); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 90c19a9..996c09b 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -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 { + // 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 { + 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 { + 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}`, ); } }