diff --git a/src/hiboutik/brand.hiboutik.repository.ts b/src/hiboutik/brand.hiboutik.repository.ts new file mode 100644 index 0000000..dca990f --- /dev/null +++ b/src/hiboutik/brand.hiboutik.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto'; + +/** Reads brands from the Hiboutik API. */ +@Injectable() +export class BrandHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + async getBrands(): Promise { + return this.hiboutikClient.getAllPages('/brands', { + order_by: 'brand_id', + sort: 'ASC', + }); + } +} diff --git a/src/hiboutik/category.hiboutik.repository.ts b/src/hiboutik/category.hiboutik.repository.ts new file mode 100644 index 0000000..43ef168 --- /dev/null +++ b/src/hiboutik/category.hiboutik.repository.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto'; +import { HiboutikClient } from './hiboutik.client'; + +/** Reads categories from the Hiboutik API. */ +@Injectable() +export class CategoryHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** Sorted by parent id, which does not guarantee that a parent comes before its children. */ + async getCategories(): Promise { + return this.hiboutikClient.getAllPages('/categories', { + order_by: 'category_id_parent', + sort: 'ASC', + }); + } +} diff --git a/src/hiboutik/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts new file mode 100644 index 0000000..95d0dc3 --- /dev/null +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { CustomerHiboutikDto, FullCustomerHiboutikDto } from '../models/hiboutikDTO/customer.hiboutik.dto'; + +/** Reads customers from the Hiboutik API. */ +@Injectable() +export class CustomerHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** Customer summaries only: use {@link getCustomerById} for addresses and sales. */ + async getCustomers(): Promise { + return this.hiboutikClient.getAllPages('/customers'); + } + + /** + * Full customer, with its addresses and sales (Hiboutik answers with a one-element array). + * @throws if the customer does not exist. + */ + async getCustomerById(id: number): Promise { + const data = await this.hiboutikClient.get('/customer/' + String(id)); + const customer = data[0]; + if (!customer) throw new Error(`Hiboutik customer ${id} not found`); + return customer; + } +} diff --git a/src/hiboutik/hiboutik.client.ts b/src/hiboutik/hiboutik.client.ts new file mode 100644 index 0000000..049806d --- /dev/null +++ b/src/hiboutik/hiboutik.client.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; + +/** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */ +const HIBOUTIK_PAGE_SIZE = 250; + +/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */ +@Injectable() +export class HiboutikClient { + private readonly baseUrl: string; + private readonly authHeader: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + const username = this.configService.get('HIBOUTIK_USERNAME') ?? ''; + const password = this.configService.get('HIBOUTIK_PASSWORD') ?? ''; + this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + this.baseUrl = this.configService.get('HIBOUTIK_BASE_URL') ?? ''; + } + + /** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */ + async get(path: string, params?: Record): Promise { + const response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}${path}`, { + headers: { Authorization: this.authHeader }, + params, + }), + ); + return response.data; + } + + /** + * Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results. + * Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results. + */ + async getAllPages(path: string, baseParams: Record = {}): Promise { + const results: T[] = []; + let page = 1; + + while (true) { + const pageData = await this.get(path, { ...baseParams, p: page }); + if (!pageData || pageData.length === 0) break; + results.push(...pageData); + // A page smaller than the page size is the last one: no need to request an empty page + if (pageData.length < HIBOUTIK_PAGE_SIZE) break; + page++; + } + + return results; + } +} diff --git a/src/hiboutik/product.hiboutik.repository.ts b/src/hiboutik/product.hiboutik.repository.ts new file mode 100644 index 0000000..25db422 --- /dev/null +++ b/src/hiboutik/product.hiboutik.repository.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto'; + +/** Reads products from the Hiboutik API. */ +@Injectable() +export class ProductHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** All products, archived ones included, with their sizes and per-size rules. */ + async getProducts(): Promise { + return this.hiboutikClient.getAllPages('/products', { + order_by: 'product_id', + sort: 'ASC', + }); + } +} diff --git a/src/hiboutik/stock.hiboutik.repository.ts b/src/hiboutik/stock.hiboutik.repository.ts new file mode 100644 index 0000000..80d893d --- /dev/null +++ b/src/hiboutik/stock.hiboutik.repository.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto'; + +/** Reads available stocks from the Hiboutik API. */ +@Injectable() +export class StockHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** Stock of every product and size, all warehouses combined. Not paginated. */ + async getStocks(): Promise { + const stocks = await this.hiboutikClient.get('/stock_available/all_wh', {}); + // Hiboutik may return ids as strings: normalize them so strict comparisons work downstream + return stocks.map((stock) => ({ + ...stock, + product_id: Number(stock.product_id), + product_size: Number(stock.product_size), + })); + } +} diff --git a/src/hiboutik/supplier.hiboutik.repository.ts b/src/hiboutik/supplier.hiboutik.repository.ts new file mode 100644 index 0000000..90fa397 --- /dev/null +++ b/src/hiboutik/supplier.hiboutik.repository.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { HiboutikClient } from './hiboutik.client'; +import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto'; + +/** Reads suppliers from the Hiboutik API. */ +@Injectable() +export class SupplierHiboutikRepository { + constructor(private readonly hiboutikClient: HiboutikClient) {} + + /** `/suppliers` is not paginated: everything comes in one call. */ + async getSuppliers(): Promise { + return this.hiboutikClient.get('/suppliers', { + order_by: 'supplier_id', + sort: 'ASC', + }); + } +} diff --git a/src/models/hiboutikDTO/brand.hiboutik.dto.ts b/src/models/hiboutikDTO/brand.hiboutik.dto.ts new file mode 100644 index 0000000..e11f99a --- /dev/null +++ b/src/models/hiboutikDTO/brand.hiboutik.dto.ts @@ -0,0 +1,7 @@ +/** Brand as returned by Hiboutik `GET /brands`. */ +export interface BrandHiboutikDto { + brand_id: number; + brand_name: string; + /** 1 = enabled, 0 = disabled. */ + brand_enabled: number; +} diff --git a/src/models/hiboutikDTO/category.hiboutik.dto.ts b/src/models/hiboutikDTO/category.hiboutik.dto.ts new file mode 100644 index 0000000..30732a1 --- /dev/null +++ b/src/models/hiboutikDTO/category.hiboutik.dto.ts @@ -0,0 +1,18 @@ +/** Category as returned by Hiboutik `GET /categories`. */ +export interface CategoryHiboutikDto { + category_id: number; + category_name: string; + /** Id of the parent category, 0 for a root category. */ + category_id_parent: number; + /** Enabled in the POS: 1 = yes, 0 = no. */ + category_enabled: number; + /** Displayed on the website: 1 = yes, 0 = no. */ + category_enabled_www: number; + /** Display order among its siblings. */ + category_position: number; + category_bck_color: string; + category_color: string; + category_desc: string; + accounting_account: string; + category_ref_ext: string; +} diff --git a/src/models/hiboutikDTO/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts new file mode 100644 index 0000000..f945d69 --- /dev/null +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -0,0 +1,79 @@ +/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */ +export interface CustomerHiboutikDto { + customers_id: number; + last_name: string; + first_name: string; + company: string; + email: string; + phone: string; + vat: string; + country: string; + /** "YYYY-MM-DD", "0000-00-00" when unknown. */ + date_of_birth: string; + customers_code: string; + loyalty_points: number; + intial_loyalty_points: number; + validity: string; + prepaid_purchases: string; + store_credit: string; + turnover: string; + orders: number; + customer_ref_ext: string; + last_order_date: string; + /** Local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: string; +} + +/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */ +export interface FullCustomerHiboutikDto extends CustomerHiboutikDto { + gender: number; + comments: string; + customers_id_referrer: number; + customers_misc: string; + accounting_account: string; + addresses: CustomerAddressHiboutikDto[]; + sales: CustomerSaleHiboutikDto[]; + tags: unknown[]; + customers_referee: unknown[]; +} + +/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */ +export interface CustomerAddressHiboutikDto { + address_id: number; + gender: string; + first_name: string; + last_name: string; + creation_date: string; + email: string; + phone: string; + company: string; + address: string; + zip_code: string; + city: string; + state: string; + country: string; + customers_id: number; + display: number; + editable: number; + /** 1 for the customer's default address. */ + default: number; + other: string; +} + +/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */ +export interface CustomerSaleHiboutikDto { + sale_id: number; + created_at: string; + completed_at: string; + date_z: number; + store_id: number; + vendor_id: number; + unique_sale_id: string; + customer_id: number; + currency: string; + /** Decimal as a string. */ + total: string; + points: number; + billing_address: number; + shipping_address: number; +} diff --git a/src/models/hiboutikDTO/product.hiboutik.dto.ts b/src/models/hiboutikDTO/product.hiboutik.dto.ts new file mode 100644 index 0000000..6080276 --- /dev/null +++ b/src/models/hiboutikDTO/product.hiboutik.dto.ts @@ -0,0 +1,46 @@ +/** Product as returned by Hiboutik `GET /products` (archived products included). */ +export interface ProductHiboutikDto { + product_id: number; + /** Product name. */ + product_model: string; + /** Empty string when the product has no barcode. */ + product_barcode: string; + /** Brand id, 0 when none. */ + product_brand: number; + /** Supplier id, 0 when none. */ + product_supplier: number; + /** Decimal as a string, e.g. "13.99". */ + product_price: string; + /** "0.00" when the product has no discount. */ + product_discount_price: string; + product_supply_price: string; + /** Category id, 0 when none. */ + product_category: number; + /** Displayed on the website: 1 = yes, 0 = no. */ + product_display_www: number; + /** Archived: 1 = yes, 0 = no. */ + product_arch: number; + products_desc: string; + /** Local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: string; + /** Per-size overrides of the product's price, discount and barcode. */ + product_specific_rules: ProductSpecificRulesHiboutikDto[]; + /** Sizes of the product; missing or empty for a product without sizes. */ + product_size_details?: ProductSizeDetailsHiboutikDto[]; +} + +/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */ +export interface ProductSpecificRulesHiboutikDto { + size_id: number; + barcode: string; + product_price?: string; + product_discount_price?: string; +} + +/** One size of a product (e.g. "S", "M"). */ +export interface ProductSizeDetailsHiboutikDto { + size_id: number; + size_name: string; + /** Empty string when the size has no barcode. */ + barcode: string; +} diff --git a/src/models/hiboutikDTO/stock.hiboutik.dto.ts b/src/models/hiboutikDTO/stock.hiboutik.dto.ts new file mode 100644 index 0000000..45d7bea --- /dev/null +++ b/src/models/hiboutikDTO/stock.hiboutik.dto.ts @@ -0,0 +1,10 @@ +/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */ +export interface StockHiboutikDto { + product_id: number; + /** Size id, 0 for a product without sizes. */ + product_size: number; + /** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */ + stock_available: string; + product_barcode: string; + product_supplier_reference: string; +} diff --git a/src/models/hiboutikDTO/supplier.hiboutik.dto.ts b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts new file mode 100644 index 0000000..10dac3d --- /dev/null +++ b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts @@ -0,0 +1,7 @@ +/** Supplier as returned by Hiboutik `GET /suppliers`. */ +export interface SupplierHiboutikDto { + supplier_id: number; + supplier_name: string; + /** 1 = enabled, 0 = disabled. */ + supplier_enabled: number; +}