Compare commits
9 Commits
main
...
8b341c4933
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b341c4933 | |||
| 9dddf7562a | |||
|
|
c5f65dcb62 | ||
|
|
27d0e0149d | ||
|
|
db699822f9 | ||
|
|
2424a086e0 | ||
|
|
8f377a1fe9 | ||
|
|
a0ddef240c | ||
|
|
24b699cfc4 |
17
.claude/settings.json
Normal file
17
.claude/settings.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run build)",
|
||||
"Bash(npm test)",
|
||||
"Bash(npm run test:e2e)",
|
||||
"Bash(npx jest:*)",
|
||||
"Bash(npx prisma generate --config prisma7.config.ts)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(./.env)",
|
||||
"Read(./.env.*)",
|
||||
"Bash(cat .env*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -37,10 +37,8 @@ lerna-debug.log*
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
@@ -54,3 +52,18 @@ pids
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Prisma client (regenerated by `prisma generate`, run on npm install)
|
||||
/prisma/generated/
|
||||
|
||||
# AI agent skills installed from skills-lock.json (keep skills-lock.json committed)
|
||||
/.agents/
|
||||
/.windsurf/
|
||||
/.claude/skills/
|
||||
# Claude Code personal settings (.claude/settings.json stays shareable)
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Postman (skills fetched by `postman init`, personal workspace binding)
|
||||
/postman/
|
||||
/.postman/
|
||||
/AGENTS.md
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"printWidth": 140,
|
||||
"tabWidth": 2
|
||||
}
|
||||
|
||||
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
Backend of a K-pop online shop. The real shop (catalog, stock, customers, sales) lives in **Hiboutik**, a POS/e-commerce platform; Hiboutik stays the source of truth. This backend sits in front of it as a buffer: it mirrors Hiboutik data into its own database so the storefront does not hammer the Hiboutik API with requests. It is also the place to build shop features Hiboutik does not offer, on top of the mirrored data.
|
||||
|
||||
Stack: NestJS 11, PostgreSQL via **Prisma 7**, Hiboutik REST API as the upstream.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run start:dev # watch mode (port from PORT, default 3000)
|
||||
npm run build # nest build -> dist/
|
||||
npm run lint # eslint --fix on src/test
|
||||
npm run format # prettier (singleQuote, trailingComma all, printWidth 140)
|
||||
npm test # unit tests (*.spec.ts under src/)
|
||||
npx jest src/app.controller.spec.ts # single test file
|
||||
npx jest -t "test name" # single test by name
|
||||
npm run test:e2e # e2e tests (test/jest-e2e.json)
|
||||
```
|
||||
|
||||
Jest runs in CommonJS: its config (in `package.json` and `test/jest-e2e.json`) transpiles the ESM-only `@nestjs/config` (`transformIgnorePatterns`) and maps `./x.js` imports to `./x` (`moduleNameMapper`) for the generated Prisma client. Keep both configs in sync. Unit tests build services directly with mocked repositories; the e2e test boots `AppModule` with `PrismaService` and `HiboutikService` overridden, so it needs neither a database nor Hiboutik.
|
||||
|
||||
Prisma: the config file is `prisma7.config.ts` (non-default name), so pass it explicitly:
|
||||
|
||||
```bash
|
||||
npx prisma generate --config prisma7.config.ts
|
||||
npx prisma migrate dev --name <name> --config prisma7.config.ts
|
||||
```
|
||||
|
||||
The client is generated into `prisma/generated/` (generator `prisma-client`, not the legacy `prisma-client-js`) and must be regenerated after any `schema.prisma` change. Code imports types from there, e.g. `prisma/generated/client` and `prisma/generated/models/<Model>` (`ProductCreateInput`, etc.).
|
||||
|
||||
## Environment
|
||||
|
||||
Read from `.env` (`@nestjs/config`, global): `DATABASE_URL` (used by Prisma and `PrismaService`), `HIBOUTIK_BASE_URL`, `HIBOUTIK_USERNAME`, `HIBOUTIK_PASSWORD` (HTTP Basic auth). HTTP calls to Hiboutik time out after 30 s (`HttpModule.register` in `AppModule`).
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything is registered as providers in a single `AppModule` (`src/app.module.ts`); new repositories/converters/services must be added there. `PrismaModule` (in `prisma/`, outside `src/`) is global.
|
||||
|
||||
Sync pipeline, one entity per layer:
|
||||
|
||||
1. **`src/hiboutik/*.hiboutik.repository.ts`** – fetch raw data from Hiboutik through `HiboutikClient` (`get`, and `getAllPages`, which walks the `p` page param until a page smaller than 250 results, Hiboutik's page size). `/suppliers` and `/stock_available/all_wh` are not paginated.
|
||||
2. **`src/models/hiboutikDTO/`** – snake_case types mirroring Hiboutik API payloads.
|
||||
3. **`src/models/converter/`** – classes extending abstract `Converter<Model, Dto>`; `fromHiboutik` maps a DTO to a Prisma `*CreateInput`. Relations use `connect: { hiboutikId }`.
|
||||
4. **`src/database/*.database.repository.ts`** – Prisma access, mainly `upsert` keyed on the unique `hiboutikId`.
|
||||
5. **`src/services/hiboutik/*.hiboutik.service.ts`** – per-entity sync orchestration; `HiboutikService.syncAll` runs them in dependency order: brands → categories → suppliers → products → customers (products `connect` to the first three by `hiboutikId`, so order matters).
|
||||
|
||||
Domain notes:
|
||||
- Synced models carry a unique `hiboutikId` (join key with Hiboutik), except `Order` (nullable `hiboutikSaleId`), `ProductVersion` (unique `[productId, hiboutikSizeId]`) and `OrderLine`; the internal `id` is a UUID. Empty Hiboutik barcodes are stored as `null`. `ProductVersion.barcode` is indexed but **not unique** (Hiboutik may reuse a barcode).
|
||||
- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted (skipped if Hiboutik returns an empty list). Products are deleted before the upserts (so a recreated product can reuse its barcode); their versions go by cascade, and versions missing from a product are deleted before its versions are upserted. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept.
|
||||
- Brands, categories and suppliers are upserted through `forEachSafely` (`src/utils/sync.ts`): a failing row is logged and skipped. Each sync step and each product/customer is in a try/catch: failures are logged, the sync goes on, and a summary is logged at the end.
|
||||
- Categories form a tree: parents are linked in a second pass (`setParent`). `enabled` mirrors `category_enabled` (POS), `webEnabled` mirrors `category_enabled_www`.
|
||||
- Products: `ProductVersion` rows come from `product_size_details` (barcode, price/discount overrides from `product_specific_rules`, stock from the stocks endpoint); no sizes means one version with `hiboutikSizeId: 0`. `globalStock` is the sum of available stock. `archived` mirrors `product_arch`.
|
||||
- `GET /customer/:id` embeds `addresses` and `sales` (typed in `customer.hiboutik.dto.ts`); `sales` only has headers, lines come from `GET /sales/:id` (`SaleHiboutikRepository`).
|
||||
- Addresses (from the embedded array): missing ones are deleted, the rest upserted via `forEachSafely`; no array means stored addresses are left untouched. Each has its own recipient fields, empty optional values stored as `null`. Company details (`taxNumber`, `companyNumber`, `legalStatus`) come from `GET /customers_addresses/:id`, only for addresses with a `company` (`null` otherwise, untouched if the call fails).
|
||||
- Orders come from the customer's `sales`: sales already stored as `COMPLETED` are skipped, the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted.
|
||||
- Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
|
||||
|
||||
## API
|
||||
|
||||
If the Postman plugin is installed, load its `postman:api-engineer` skill before any non-trivial API engineering task (designing, implementing, mocking, testing, monitoring, documenting, deploying). Pure questions and trivial one-line edits do not need it. The local `postman/` and `.postman/` folders are generated by `postman init` and are git-ignored.
|
||||
|
||||
## Git workflow
|
||||
|
||||
The remote is a Gitea instance (`origin`). Use plain `git` or the Gitea CLI (`gt`) for pull requests and other Gitea operations.
|
||||
|
||||
- Never commit or push directly to the integration branch: always push changes on a `feature/<name>` branch.
|
||||
- Merges go into `develop`. Until `develop` exists (early project phase), they go into `main`.
|
||||
- Commit messages follow Conventional Commits (`feat(sync): ...`, `fix(db): ...`).
|
||||
@@ -31,6 +31,14 @@
|
||||
$ npm install
|
||||
```
|
||||
|
||||
### AI agent skills (optional)
|
||||
|
||||
The Prisma skills used by Claude Code are pinned in `skills-lock.json`; the skill folders themselves are not committed. Restore them with:
|
||||
|
||||
```bash
|
||||
$ npx skills experimental_install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,8 +19,9 @@ export default tseslint.config(
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
//projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -29,7 +30,7 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
"prettier/prettier": "error",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
5749
package-lock.json
generated
5749
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -6,6 +6,7 @@
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate --config prisma7.config.ts",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
@@ -22,21 +23,26 @@
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^12.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"axios": "^1.17.0",
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"axios": "1.20.0",
|
||||
"pg": "^8.23.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/cli": "^12.0.11",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -44,6 +50,7 @@
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^7.10.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
@@ -62,12 +69,33 @@
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+.ts$": "ts-jest",
|
||||
"^.+.js$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": {
|
||||
"allowJs": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!@nestjs/config/)"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^(.{1,2}/.*).js$": "$1"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"@parcel/watcher@2.6.0": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"prisma@7.10.1": true
|
||||
}
|
||||
}
|
||||
|
||||
12
prisma/customObjects/prisma.custom.object.ts
Normal file
12
prisma/customObjects/prisma.custom.object.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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[];
|
||||
};
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
9
prisma/prisma.module.ts
Normal file
9
prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
20
prisma/prisma.service.ts
Normal file
20
prisma/prisma.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from './generated/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
/** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
229
prisma/schema.prisma
Normal file
229
prisma/schema.prisma
Normal file
@@ -0,0 +1,229 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated/"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
/// Product category, mirrored from Hiboutik. Categories form a tree (parent / children).
|
||||
model Category {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Enabled in the Hiboutik POS (category_enabled)
|
||||
enabled Boolean @default(true)
|
||||
hiboutikId Int @unique
|
||||
name String
|
||||
/// Display order among its siblings (category_position)
|
||||
position Int @default(0)
|
||||
/// Displayed on the website (category_enabled_www)
|
||||
webEnabled Boolean @default(true)
|
||||
|
||||
/// Null for a root category
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
parentId String?
|
||||
children Category[] @relation("CategoryTree")
|
||||
|
||||
products Product[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
/// Product brand, mirrored from Hiboutik.
|
||||
model Brand {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
enabled Boolean @default(true)
|
||||
hiboutikId Int @unique
|
||||
name String
|
||||
|
||||
products Product[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
/// Product supplier, mirrored from Hiboutik.
|
||||
model Supplier {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
enabled Boolean @default(true)
|
||||
hiboutikId Int @unique
|
||||
name String
|
||||
|
||||
products Product[]
|
||||
|
||||
@@map("suppliers")
|
||||
}
|
||||
|
||||
/// Product mirrored from Hiboutik. What is actually sold is one of its versions (sizes).
|
||||
model Product {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Archived in Hiboutik (product_arch)
|
||||
archived Boolean @default(false)
|
||||
barcode String?
|
||||
description String
|
||||
/// Null when the product has no discount
|
||||
discountPrice Decimal? @db.Decimal(10, 2)
|
||||
/// Displayed on the website (product_display_www)
|
||||
enabled Boolean @default(true)
|
||||
/// Sum of the available stock of all its versions
|
||||
globalStock Int @default(0)
|
||||
hiboutikId Int @unique
|
||||
/// Last update in Hiboutik
|
||||
hiboutikUpdatedAt DateTime?
|
||||
name String
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
brandId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
|
||||
supplierId String?
|
||||
|
||||
versions ProductVersion[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
/// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes.
|
||||
model ProductVersion {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// Not unique: Hiboutik is the source of truth and may hold the same barcode on several products
|
||||
barcode String?
|
||||
/// Size-specific discount, null when none
|
||||
discountPrice Decimal? @db.Decimal(10, 2)
|
||||
/// Hiboutik size id, 0 for a product without sizes
|
||||
hiboutikSizeId Int
|
||||
/// Size-specific price, null to use the product price (copy of the product price for a product without sizes)
|
||||
price Decimal? @db.Decimal(10, 2)
|
||||
stock Int @default(0)
|
||||
/// Size name, or the product name for a product without sizes
|
||||
versionName String
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: Restrict)
|
||||
productId String
|
||||
|
||||
orderLines OrderLine[]
|
||||
|
||||
@@unique([productId, hiboutikSizeId])
|
||||
@@index([barcode])
|
||||
@@map("productVersions")
|
||||
}
|
||||
|
||||
/// Customer mirrored from Hiboutik.
|
||||
model Customer {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
dateOfBirth DateTime?
|
||||
/// Kept when removed in Hiboutik (an empty Hiboutik email does not erase it)
|
||||
email String?
|
||||
firstName String
|
||||
hiboutikId Int @unique
|
||||
/// Last update in Hiboutik
|
||||
hiboutikUpdatedAt DateTime?
|
||||
lastName String
|
||||
phone String?
|
||||
|
||||
addresses CustomerAddress[]
|
||||
orders Order[]
|
||||
|
||||
@@index([email])
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
/// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
|
||||
model CustomerAddress {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
address String
|
||||
city String
|
||||
company String?
|
||||
/// Company registration number (SIRET...), only for an address with a company
|
||||
companyNumber String?
|
||||
country String
|
||||
firstName String
|
||||
hiboutikId Int @unique
|
||||
isDefault Boolean @default(false)
|
||||
lastName String
|
||||
/// Company legal status (SAS, SARL...), only for an address with a company
|
||||
legalStatus String?
|
||||
/// Free complement (building, floor, door code...)
|
||||
other String?
|
||||
phone String?
|
||||
state String?
|
||||
/// Company VAT number, only for an address with a company
|
||||
taxNumber String?
|
||||
zipCode String
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
customerId String
|
||||
|
||||
@@map("customer_addresses")
|
||||
}
|
||||
|
||||
/// Where an order was placed.
|
||||
enum OrderSource {
|
||||
STORE // hiboutik
|
||||
WEB // site
|
||||
}
|
||||
|
||||
/// Order placed in store (Hiboutik sale) or on the website.
|
||||
model Order {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
/// Null for a website order not (yet) sent to Hiboutik
|
||||
hiboutikSaleId Int? @unique
|
||||
orderedAt DateTime
|
||||
source OrderSource
|
||||
status String
|
||||
total Decimal @db.Decimal(10, 2)
|
||||
|
||||
/// Null for an anonymous sale or a deleted customer
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
customerId String?
|
||||
|
||||
lines OrderLine[]
|
||||
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
/// Line of an order.
|
||||
model OrderLine {
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Snapshot of the product at order time: the catalog can change or be deleted afterwards
|
||||
barcode String?
|
||||
hiboutikProductId Int
|
||||
hiboutikSizeId Int
|
||||
productName String
|
||||
quantity Int
|
||||
unitPrice Decimal @db.Decimal(10, 2)
|
||||
versionName String
|
||||
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
orderId String
|
||||
// Link to the current catalog, set to null when the version is deleted
|
||||
productVersion ProductVersion? @relation(fields: [productVersionId], references: [id], onDelete: SetNull)
|
||||
productVersionId String?
|
||||
|
||||
@@map("order_lines")
|
||||
}
|
||||
14
prisma7.config.ts
Normal file
14
prisma7.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import 'dotenv/config';
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: process.env['DATABASE_URL'],
|
||||
},
|
||||
});
|
||||
59
skills-lock.json
Normal file
59
skills-lock.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"prisma-cli": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-cli/SKILL.md",
|
||||
"computedHash": "b92e55aef78f6796d81433c44738a6733211a04c16e65cbad6d42c5f71092aab"
|
||||
},
|
||||
"prisma-client-api": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-client-api/SKILL.md",
|
||||
"computedHash": "5dcc0793337151efa73a444e5adbc7e1c24180778e83b4fe94c9109c391bd333"
|
||||
},
|
||||
"prisma-compute": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-compute/SKILL.md",
|
||||
"computedHash": "e5695a3c6f35428e96df30ae662716bd4c9d9074f72cc9334b3050445a1bd4e2"
|
||||
},
|
||||
"prisma-database-setup": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-database-setup/SKILL.md",
|
||||
"computedHash": "0911d9454bd48df3badd23a50fd90a280eabb15dcbd472c14e7b729075dadefb"
|
||||
},
|
||||
"prisma-driver-adapter-implementation": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-driver-adapter-implementation/SKILL.md",
|
||||
"computedHash": "07484627aea6cce4d0f94b4090ff9acc0caa7e104f9988b95abecf80132f4103"
|
||||
},
|
||||
"prisma-mongodb-upgrade": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-mongodb-upgrade/SKILL.md",
|
||||
"computedHash": "f9ba440e88ca4cec9801d04762296e29e99ca08cb8a8156e4f783ecf90279c8f"
|
||||
},
|
||||
"prisma-postgres": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-postgres/SKILL.md",
|
||||
"computedHash": "d669fbd0d8017d16c967f18b20e1c1345cd4a2a0076d762459bfef79f551f655"
|
||||
},
|
||||
"prisma-postgres-setup": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-postgres-setup/SKILL.md",
|
||||
"computedHash": "c89d3aa91285d8e4964fcaa5238c99a3d3c20b617e032e731cf632330a85a5a6"
|
||||
},
|
||||
"prisma-upgrade-v7": {
|
||||
"source": "prisma/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "prisma-upgrade-v7/SKILL.md",
|
||||
"computedHash": "dcc6c71adca6b22f37c5bda5ac7fd63c3bb59495596a22e06a29c7c85371558f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
let controller: AppController;
|
||||
const hiboutikService = { startSync: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
hiboutikService.startSync.mockReset();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [{ provide: HiboutikService, useValue: hiboutikService }],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
controller = module.get(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
it('starts a synchronization', () => {
|
||||
hiboutikService.startSync.mockReturnValue(true);
|
||||
expect(controller.hiboutikSync()).toEqual({ message: 'Hiboutik synchronization started' });
|
||||
expect(hiboutikService.startSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a synchronization when one is already running', () => {
|
||||
hiboutikService.startSync.mockReturnValue(false);
|
||||
expect(() => controller.hiboutikSync()).toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
import { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
constructor(private readonly hiboutikService: HiboutikService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
/** Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. */
|
||||
@Post('sync')
|
||||
@HttpCode(HttpStatus.ACCEPTED)
|
||||
hiboutikSync() {
|
||||
if (!this.hiboutikService.startSync()) {
|
||||
throw new ConflictException('A Hiboutik synchronization is already in progress');
|
||||
}
|
||||
return { message: 'Hiboutik synchronization started' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,77 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||
import { CategoryHiboutikRepository } from './hiboutik/category.hiboutik.repository';
|
||||
import { CategoryConverter } from './models/converter/category.converter';
|
||||
import { ProductHiboutikRepository } from './hiboutik/product.hiboutik.repository';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CategoryDatabaseRepository } from './database/category.database.repository';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { HiboutikClient } from './hiboutik/hiboutik.client';
|
||||
import { BrandHiboutikRepository } from './hiboutik/brand.hiboutik.repository';
|
||||
import { BrandDatabaseRepository } from './database/brand.database.repository';
|
||||
import { BrandConverter } from './models/converter/brand.converter';
|
||||
import { SupplierHiboutikRepository } from './hiboutik/supplier.hiboutik.repository';
|
||||
import { SupplierDatabaseRepository } from './database/supplier.database.repository';
|
||||
import { SupplierConverter } from './models/converter/supplier.converter';
|
||||
import { StockHiboutikRepository } from './hiboutik/stock.hiboutik.repository';
|
||||
import { ProductConverter } from './models/converter/product.converter';
|
||||
import { ProductDatabaseRepository } from './database/product.database.repository';
|
||||
import { ProductVersionDatabaseRepository } from './database/productVersion.database.repository';
|
||||
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';
|
||||
import { SupplierHiboutikService } from './services/hiboutik/supplier.hiboutik.service';
|
||||
import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
HttpModule.register({ timeout: 30_000 }),
|
||||
PrismaModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
providers: [
|
||||
BrandConverter,
|
||||
BrandDatabaseRepository,
|
||||
BrandHiboutikRepository,
|
||||
BrandHiboutikService,
|
||||
CategoryConverter,
|
||||
CategoryDatabaseRepository,
|
||||
CategoryHiboutikRepository,
|
||||
CategoryHiboutikService,
|
||||
CustomerAddressConverter,
|
||||
CustomerAddressDatabaseRepository,
|
||||
CustomerHiboutikRepository,
|
||||
CustomerDatabaseRepository,
|
||||
CustomerConverter,
|
||||
CustomerHiboutikService,
|
||||
HiboutikClient,
|
||||
HiboutikService,
|
||||
OrderConverter,
|
||||
OrderDatabaseRepository,
|
||||
ProductConverter,
|
||||
ProductDatabaseRepository,
|
||||
ProductHiboutikRepository,
|
||||
ProductHiboutikService,
|
||||
ProductVersionDatabaseRepository,
|
||||
SaleHiboutikRepository,
|
||||
StockHiboutikRepository,
|
||||
SupplierConverter,
|
||||
SupplierDatabaseRepository,
|
||||
SupplierHiboutikRepository,
|
||||
SupplierHiboutikService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
36
src/database/brand.database.repository.ts
Normal file
36
src/database/brand.database.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Brand } from '../../prisma/generated/client';
|
||||
import { BrandCreateInput } from '../../prisma/generated/models/Brand';
|
||||
|
||||
/** Database access to brands. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class BrandDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Brand[]> {
|
||||
return this.prisma.brand.findMany({
|
||||
orderBy: { hiboutikId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the brand with the same `hiboutikId`. */
|
||||
async upsert(brand: BrandCreateInput): Promise<Brand> {
|
||||
return this.prisma.brand.upsert({
|
||||
where: { hiboutikId: brand.hiboutikId },
|
||||
create: brand,
|
||||
update: brand,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the brands that no longer exist in Hiboutik.
|
||||
* Their products are kept: the relation is set to null (onDelete: SetNull).
|
||||
*/
|
||||
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.brand.deleteMany({
|
||||
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
44
src/database/category.database.repository.ts
Normal file
44
src/database/category.database.repository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Category } from '../../prisma/generated/client';
|
||||
import { CategoryCreateInput } from '../../prisma/generated/models/Category';
|
||||
|
||||
/** Database access to categories. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class CategoryDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Category[]> {
|
||||
return this.prisma.category.findMany({
|
||||
orderBy: { hiboutikId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the category with the same `hiboutikId`. */
|
||||
async upsert(category: CategoryCreateInput): Promise<Category> {
|
||||
return this.prisma.category.upsert({
|
||||
where: { hiboutikId: category.hiboutikId },
|
||||
create: category,
|
||||
update: category,
|
||||
});
|
||||
}
|
||||
|
||||
/** Links a category to its parent (null for a root category). Both must already exist. */
|
||||
async setParent(hiboutikId: number, parentHiboutikId: number | null): Promise<Category> {
|
||||
return this.prisma.category.update({
|
||||
where: { hiboutikId },
|
||||
data: { parent: parentHiboutikId ? { connect: { hiboutikId: parentHiboutikId } } : { disconnect: true } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the categories that no longer exist in Hiboutik.
|
||||
* Their products and sub-categories are kept: the relation is set to null (onDelete: SetNull).
|
||||
*/
|
||||
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.category.deleteMany({
|
||||
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
36
src/database/customer.database.repository.ts
Normal file
36
src/database/customer.database.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Customer } from '../../prisma/generated/client';
|
||||
import { CustomerCreateInput } from '../../prisma/generated/models/Customer';
|
||||
|
||||
/** Database access to customers. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class CustomerDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Customer[]> {
|
||||
return this.prisma.customer.findMany({
|
||||
orderBy: { hiboutikId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the customer with the same `hiboutikId`. */
|
||||
async upsert(customer: CustomerCreateInput): Promise<Customer> {
|
||||
return this.prisma.customer.upsert({
|
||||
where: { hiboutikId: customer.hiboutikId },
|
||||
create: customer,
|
||||
update: customer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the customers that no longer exist in Hiboutik.
|
||||
* Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull).
|
||||
*/
|
||||
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.customer.deleteMany({
|
||||
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
27
src/database/customerAddress.database.repository.ts
Normal file
27
src/database/customerAddress.database.repository.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CustomerAddress } from '../../prisma/generated/client';
|
||||
import { CustomerAddressCreateInput } from '../../prisma/generated/models/CustomerAddress';
|
||||
|
||||
/** Database access to customer addresses. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class CustomerAddressDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Creates or updates the address with the same `hiboutikId`. */
|
||||
async upsert(address: CustomerAddressCreateInput): Promise<CustomerAddress> {
|
||||
return this.prisma.customerAddress.upsert({
|
||||
where: { hiboutikId: address.hiboutikId },
|
||||
create: address,
|
||||
update: address,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the addresses of a customer that no longer exist in Hiboutik (all of them if `keptHiboutikIds` is empty). */
|
||||
async deleteMissing(customerId: string, keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.customerAddress.deleteMany({
|
||||
where: { customerId, hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
61
src/database/order.database.repository.ts
Normal file
61
src/database/order.database.repository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Order } from '../../prisma/generated/client';
|
||||
import { OrderWithLinesInput } from '../../prisma/customObjects/prisma.custom.object';
|
||||
import { OrderStatus } from '../models/orderStatus';
|
||||
|
||||
/** Database access to orders. Hiboutik sales are matched by `hiboutikSaleId`. */
|
||||
@Injectable()
|
||||
export class OrderDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Among `hiboutikSaleIds`, the ones already stored as completed orders. */
|
||||
async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise<Set<number>> {
|
||||
if (hiboutikSaleIds.length === 0) return new Set();
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED },
|
||||
select: { hiboutikSaleId: true },
|
||||
});
|
||||
return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or updates the order with the same `hiboutikSaleId`, replacing all its lines (atomically).
|
||||
* Each line is linked to the current product version matching its Hiboutik product and size, if it still exists.
|
||||
* On update, `source` is kept: a website order sent to Hiboutik stays WEB.
|
||||
*/
|
||||
async upsert(order: OrderWithLinesInput): Promise<Order> {
|
||||
if (order.hiboutikSaleId == null) throw new Error('Cannot upsert an order without hiboutikSaleId');
|
||||
|
||||
const { lines, source, ...orderFields } = order;
|
||||
const linkedLines = await this.linkToProductVersions(lines);
|
||||
return this.prisma.order.upsert({
|
||||
where: { hiboutikSaleId: order.hiboutikSaleId },
|
||||
create: { ...orderFields, source, lines: { create: linkedLines } },
|
||||
update: {
|
||||
...orderFields,
|
||||
// an undefined relation is ignored by Prisma on update: explicitly unlink a customer removed from the sale
|
||||
customer: orderFields.customer ?? { disconnect: true },
|
||||
lines: { deleteMany: {}, create: linkedLines },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async linkToProductVersions(lines: OrderWithLinesInput['lines']): Promise<OrderWithLinesInput['lines']> {
|
||||
if (lines.length === 0) return lines;
|
||||
|
||||
const versions = await this.prisma.productVersion.findMany({
|
||||
where: {
|
||||
OR: lines.map((line) => ({ hiboutikSizeId: line.hiboutikSizeId, product: { hiboutikId: line.hiboutikProductId } })),
|
||||
},
|
||||
select: { id: true, hiboutikSizeId: true, product: { select: { hiboutikId: true } } },
|
||||
});
|
||||
const versionIds = new Map(versions.map((version) => [`${version.product.hiboutikId}:${version.hiboutikSizeId}`, version.id]));
|
||||
|
||||
return lines.map((line) => {
|
||||
const versionId = versionIds.get(`${line.hiboutikProductId}:${line.hiboutikSizeId}`);
|
||||
return versionId ? { ...line, productVersion: { connect: { id: versionId } } } : line;
|
||||
});
|
||||
}
|
||||
}
|
||||
44
src/database/product.database.repository.ts
Normal file
44
src/database/product.database.repository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Product } from '../../prisma/generated/client';
|
||||
import { ProductCreateInput } from '../../prisma/generated/models/Product';
|
||||
|
||||
/** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class ProductDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Product[]> {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { hiboutikId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. */
|
||||
async upsert(product: ProductCreateInput): Promise<Product> {
|
||||
return this.prisma.product.upsert({
|
||||
where: { hiboutikId: product.hiboutikId },
|
||||
create: product,
|
||||
update: {
|
||||
...product,
|
||||
// an undefined relation is ignored by Prisma on update: explicitly unlink relations removed in Hiboutik
|
||||
brand: product.brand ?? { disconnect: true },
|
||||
category: product.category ?? { disconnect: true },
|
||||
supplier: product.supplier ?? { disconnect: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes products (and their versions, by cascade).
|
||||
* Order lines keep their own snapshot of the product, their link is set to null.
|
||||
*/
|
||||
async deleteAll(productIds: string[]): Promise<number> {
|
||||
if (productIds.length === 0) return 0;
|
||||
|
||||
const result = await this.prisma.product.deleteMany({
|
||||
where: { id: { in: productIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
36
src/database/productVersion.database.repository.ts
Normal file
36
src/database/productVersion.database.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { ProductVersion } from '../../prisma/generated/client';
|
||||
import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion';
|
||||
|
||||
/** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */
|
||||
@Injectable()
|
||||
export class ProductVersionDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<ProductVersion[]> {
|
||||
return this.prisma.productVersion.findMany({
|
||||
orderBy: { hiboutikSizeId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the version of `productId` with the same `hiboutikSizeId`. */
|
||||
async upsert(productId: string, productVersion: ProductVersionCreateInput): Promise<ProductVersion> {
|
||||
return this.prisma.productVersion.upsert({
|
||||
where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } },
|
||||
create: productVersion,
|
||||
update: productVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the versions of a product that no longer exist in Hiboutik.
|
||||
* Order lines keep their own snapshot of the product, their link is set to null.
|
||||
*/
|
||||
async deleteMissing(productId: string, keptHiboutikSizeIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.productVersion.deleteMany({
|
||||
where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
36
src/database/supplier.database.repository.ts
Normal file
36
src/database/supplier.database.repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Supplier } from '../../prisma/generated/client';
|
||||
import { SupplierCreateInput } from '../../prisma/generated/models/Supplier';
|
||||
|
||||
/** Database access to suppliers. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||
@Injectable()
|
||||
export class SupplierDatabaseRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Supplier[]> {
|
||||
return this.prisma.supplier.findMany({
|
||||
orderBy: { hiboutikId: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates the supplier with the same `hiboutikId`. */
|
||||
async upsert(supplier: SupplierCreateInput): Promise<Supplier> {
|
||||
return this.prisma.supplier.upsert({
|
||||
where: { hiboutikId: supplier.hiboutikId },
|
||||
create: supplier,
|
||||
update: supplier,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the suppliers that no longer exist in Hiboutik.
|
||||
* Their products are kept: the relation is set to null (onDelete: SetNull).
|
||||
*/
|
||||
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
||||
const result = await this.prisma.supplier.deleteMany({
|
||||
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
16
src/hiboutik/brand.hiboutik.repository.ts
Normal file
16
src/hiboutik/brand.hiboutik.repository.ts
Normal file
@@ -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<BrandHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<BrandHiboutikDto>('/brands', {
|
||||
order_by: 'brand_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
17
src/hiboutik/category.hiboutik.repository.ts
Normal file
17
src/hiboutik/category.hiboutik.repository.ts
Normal file
@@ -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<CategoryHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<CategoryHiboutikDto>('/categories', {
|
||||
order_by: 'category_id_parent',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/hiboutik/customer.hiboutik.repository.ts
Normal file
40
src/hiboutik/customer.hiboutik.repository.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HiboutikClient } from './hiboutik.client';
|
||||
import {
|
||||
CustomerAddressDetailsHiboutikDto,
|
||||
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<CustomerHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<CustomerHiboutikDto>('/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<FullCustomerHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<FullCustomerHiboutikDto[]>('/customer/' + String(id));
|
||||
const customer = data[0];
|
||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||
return customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address with its company details (Hiboutik answers with a one-element array).
|
||||
* @throws if the address does not exist.
|
||||
*/
|
||||
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
||||
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/customers_addresses/' + String(id));
|
||||
const address = data[0];
|
||||
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
||||
return address;
|
||||
}
|
||||
}
|
||||
55
src/hiboutik/hiboutik.client.ts
Normal file
55
src/hiboutik/hiboutik.client.ts
Normal file
@@ -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<string>('HIBOUTIK_USERNAME') ?? '';
|
||||
const password = this.configService.get<string>('HIBOUTIK_PASSWORD') ?? '';
|
||||
this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
||||
}
|
||||
|
||||
/** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */
|
||||
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<T>(`${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<T>(path: string, baseParams: Record<string, unknown> = {}): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const pageData = await this.get<T[]>(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;
|
||||
}
|
||||
}
|
||||
17
src/hiboutik/product.hiboutik.repository.ts
Normal file
17
src/hiboutik/product.hiboutik.repository.ts
Normal file
@@ -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<ProductHiboutikDto[]> {
|
||||
return this.hiboutikClient.getAllPages<ProductHiboutikDto>('/products', {
|
||||
order_by: 'product_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
20
src/hiboutik/stock.hiboutik.repository.ts
Normal file
20
src/hiboutik/stock.hiboutik.repository.ts
Normal file
@@ -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<StockHiboutikDto[]> {
|
||||
const stocks = await this.hiboutikClient.get<StockHiboutikDto[]>('/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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
17
src/hiboutik/supplier.hiboutik.repository.ts
Normal file
17
src/hiboutik/supplier.hiboutik.repository.ts
Normal file
@@ -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<SupplierHiboutikDto[]> {
|
||||
return this.hiboutikClient.get<SupplierHiboutikDto[]>('/suppliers', {
|
||||
order_by: 'supplier_id',
|
||||
sort: 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
19
src/models/converter/brand.converter.ts
Normal file
19
src/models/converter/brand.converter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { BrandHiboutikDto } from '../hiboutikDTO/brand.hiboutik.dto';
|
||||
import { BrandCreateInput } from '../../../prisma/generated/models/Brand';
|
||||
|
||||
@Injectable()
|
||||
export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto> {
|
||||
fromHiboutik(dto: BrandHiboutikDto): BrandCreateInput {
|
||||
return {
|
||||
hiboutikId: dto.brand_id,
|
||||
name: dto.brand_name,
|
||||
enabled: dto.brand_enabled == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): BrandHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
22
src/models/converter/category.converter.ts
Normal file
22
src/models/converter/category.converter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CategoryHiboutikDto } from '../hiboutikDTO/category.hiboutik.dto';
|
||||
import { Converter } from './converter';
|
||||
import { CategoryCreateInput } from '../../../prisma/generated/models/Category';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHiboutikDto> {
|
||||
/** The parent is not set here: it is linked afterwards, once every category exists (see CategoryHiboutikService). */
|
||||
fromHiboutik(hiboutikDto: CategoryHiboutikDto): CategoryCreateInput {
|
||||
return {
|
||||
hiboutikId: hiboutikDto.category_id,
|
||||
name: hiboutikDto.category_name,
|
||||
enabled: hiboutikDto.category_enabled == 1,
|
||||
position: Number(hiboutikDto.category_position) || 0,
|
||||
webEnabled: hiboutikDto.category_enabled_www == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): CategoryHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
29
src/models/converter/converter.ts
Normal file
29
src/models/converter/converter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Maps a Hiboutik DTO to a Prisma input, and back. One implementation per synced entity.
|
||||
* @typeParam Model Prisma `*CreateInput` of the entity.
|
||||
* @typeParam Dto Hiboutik payload of the entity.
|
||||
*/
|
||||
export abstract class Converter<Model, Dto> {
|
||||
/**
|
||||
* Builds the Prisma input from a Hiboutik DTO. It is used for both create and update (upsert):
|
||||
* a field set to `undefined` keeps its stored value on update, `null` erases it.
|
||||
*/
|
||||
abstract fromHiboutik(dto: Dto): Model;
|
||||
|
||||
/** Not implemented yet: nothing is written back to Hiboutik. */
|
||||
abstract toHiboutik(model: Model): Dto;
|
||||
|
||||
/** Returns [] for a missing or non-array input. */
|
||||
toHiboutikList(models: Model[]): Dto[] {
|
||||
if (!models || !Array.isArray(models)) return [];
|
||||
|
||||
return models.map((model) => this.toHiboutik(model));
|
||||
}
|
||||
|
||||
/** Returns [] for a missing or non-array input. */
|
||||
fromHiboutikList(hiboutikDtos: Dto[]): Model[] {
|
||||
if (!hiboutikDtos || !Array.isArray(hiboutikDtos)) return [];
|
||||
|
||||
return hiboutikDtos.map((hiboutikDto) => this.fromHiboutik(hiboutikDto));
|
||||
}
|
||||
}
|
||||
44
src/models/converter/customer.converter.spec.ts
Normal file
44
src/models/converter/customer.converter.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CustomerConverter } from './customer.converter';
|
||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto =>
|
||||
({
|
||||
customers_id: 42,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Doe',
|
||||
email: 'jane@example.com',
|
||||
phone: '0600000000',
|
||||
date_of_birth: '1990-05-12',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
...overrides,
|
||||
}) as FullCustomerHiboutikDto;
|
||||
|
||||
describe('CustomerConverter', () => {
|
||||
const converter = new CustomerConverter();
|
||||
|
||||
it('maps a Hiboutik customer', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer())).toEqual({
|
||||
dateOfBirth: new Date('1990-05-12'),
|
||||
email: 'jane@example.com',
|
||||
firstName: 'Jane',
|
||||
hiboutikId: 42,
|
||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||
lastName: 'Doe',
|
||||
phone: '0600000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an invalid date of birth', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the stored email when Hiboutik has none (undefined)', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined();
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('erases the phone when Hiboutik has none (null)', () => {
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/models/converter/customer.converter.ts
Normal file
25
src/models/converter/customer.converter.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { CustomerCreateInput } from '../../../prisma/generated/models/Customer';
|
||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerConverter extends Converter<CustomerCreateInput, FullCustomerHiboutikDto> {
|
||||
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
||||
return {
|
||||
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
||||
// undefined (not null) on purpose: an email removed in Hiboutik must not erase the one stored in database
|
||||
email: dto.email?.trim() || undefined,
|
||||
firstName: dto.first_name,
|
||||
hiboutikId: dto.customers_id,
|
||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||
lastName: dto.last_name,
|
||||
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
||||
phone: dto.phone?.trim() || null,
|
||||
};
|
||||
}
|
||||
toHiboutik(): FullCustomerHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
89
src/models/converter/customerAddress.converter.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { CustomerAddressConverter } from './customerAddress.converter';
|
||||
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto =>
|
||||
({
|
||||
address_id: 7,
|
||||
customers_id: 42,
|
||||
first_name: 'Jane',
|
||||
last_name: 'Doe',
|
||||
company: 'ACME',
|
||||
phone: '0600000000',
|
||||
address: ' 1 rue de la Paix ',
|
||||
zip_code: '75002',
|
||||
city: 'Paris',
|
||||
state: '',
|
||||
country: 'FRA',
|
||||
other: 'Bât. B',
|
||||
default: 1,
|
||||
...overrides,
|
||||
}) as CustomerAddressHiboutikDto;
|
||||
|
||||
describe('CustomerAddressConverter', () => {
|
||||
const converter = new CustomerAddressConverter();
|
||||
|
||||
it('maps a Hiboutik address', () => {
|
||||
expect(converter.fromHiboutik(hiboutikAddress())).toEqual({
|
||||
address: '1 rue de la Paix',
|
||||
city: 'Paris',
|
||||
company: 'ACME',
|
||||
country: 'FRA',
|
||||
firstName: 'Jane',
|
||||
hiboutikId: 7,
|
||||
isDefault: true,
|
||||
lastName: 'Doe',
|
||||
other: 'Bât. B',
|
||||
phone: '0600000000',
|
||||
state: null,
|
||||
zipCode: '75002',
|
||||
customer: { connect: { hiboutikId: 42 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts ids and flags sent as strings', () => {
|
||||
const address = converter.fromHiboutik(hiboutikAddress({ address_id: '8', customers_id: '43', default: '0' } as never));
|
||||
expect(address.hiboutikId).toBe(8);
|
||||
expect(address.isDefault).toBe(false);
|
||||
expect(address.customer).toEqual({ connect: { hiboutikId: 43 } });
|
||||
});
|
||||
|
||||
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
|
||||
const address = converter.fromHiboutik(
|
||||
hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }),
|
||||
);
|
||||
expect(address.company).toBeNull();
|
||||
expect(address.phone).toBeNull();
|
||||
expect(address.other).toBeNull();
|
||||
expect(address.firstName).toBe('');
|
||||
});
|
||||
|
||||
describe('company details', () => {
|
||||
const details = { tax_number: ' FR123 ', company_number: '12345678900012', legal_status: 'SAS', updated_at: '2026-11-01 10:00:00' };
|
||||
|
||||
it('maps them when fetched for an address with a company', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details });
|
||||
expect(address.taxNumber).toBe('FR123');
|
||||
expect(address.companyNumber).toBe('12345678900012');
|
||||
expect(address.legalStatus).toBe('SAS');
|
||||
});
|
||||
|
||||
it('erases empty fetched values (null)', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details, tax_number: '' });
|
||||
expect(address.taxNumber).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the stored ones when not fetched (undefined)', () => {
|
||||
const address = converter.fromHiboutik(hiboutikAddress());
|
||||
expect(address.taxNumber).toBeUndefined();
|
||||
expect(address.companyNumber).toBeUndefined();
|
||||
expect(address.legalStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it('erases them when the address has no company (null)', () => {
|
||||
const address = converter.fromHiboutik({ ...hiboutikAddress({ company: '' }), ...details });
|
||||
expect(address.taxNumber).toBeNull();
|
||||
expect(address.companyNumber).toBeNull();
|
||||
expect(address.legalStatus).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
src/models/converter/customerAddress.converter.ts
Normal file
49
src/models/converter/customerAddress.converter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
||||
import { CustomerAddressDetailsHiboutikDto, CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||
|
||||
/** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */
|
||||
const optional = (value: string | null | undefined): string | null => value?.trim() || null;
|
||||
const required = (value: string | null | undefined): string => value?.trim() ?? '';
|
||||
|
||||
/**
|
||||
* Maps an address. Company details (tax/company number, legal status) are:
|
||||
* - `null` when the address has no company,
|
||||
* - mapped when `dto` comes from `GET /customers_addresses/:id` ({@link CustomerAddressDetailsHiboutikDto}),
|
||||
* - `undefined` (stored values kept) when the details were not fetched.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerAddressConverter extends Converter<
|
||||
CustomerAddressCreateInput,
|
||||
CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto
|
||||
> {
|
||||
fromHiboutik(dto: CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto): CustomerAddressCreateInput {
|
||||
const company = optional(dto.company);
|
||||
const hasDetails = 'tax_number' in dto;
|
||||
const companyDetail = (value: string | null | undefined) => (!company ? null : hasDetails ? optional(value) : undefined);
|
||||
const details = dto as Partial<CustomerAddressDetailsHiboutikDto>;
|
||||
|
||||
return {
|
||||
address: required(dto.address),
|
||||
city: required(dto.city),
|
||||
company,
|
||||
companyNumber: companyDetail(details.company_number),
|
||||
country: required(dto.country),
|
||||
firstName: required(dto.first_name),
|
||||
hiboutikId: Number(dto.address_id),
|
||||
isDefault: Number(dto.default) === 1,
|
||||
lastName: required(dto.last_name),
|
||||
legalStatus: companyDetail(details.legal_status),
|
||||
other: optional(dto.other),
|
||||
phone: optional(dto.phone),
|
||||
state: optional(dto.state),
|
||||
taxNumber: companyDetail(details.tax_number),
|
||||
zipCode: required(dto.zip_code),
|
||||
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
|
||||
};
|
||||
}
|
||||
toHiboutik(): CustomerAddressHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
80
src/models/converter/order.converter.spec.ts
Normal file
80
src/models/converter/order.converter.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { OrderConverter } from './order.converter';
|
||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||
|
||||
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
|
||||
line_item_id: 3,
|
||||
product_id: 746,
|
||||
product_size: 1,
|
||||
quantity: 2,
|
||||
product_model: 'KATSEYE - WILD',
|
||||
product_barcode: '2430010007467',
|
||||
size_name: 'Daniela',
|
||||
item_unit_gross_wo_discount: '19.00',
|
||||
item_unit_gross: '17.10',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto =>
|
||||
({
|
||||
sale_id: 5,
|
||||
created_at: '2026-10-18 11:00:21',
|
||||
completed_at: '2026-10-18 11:01:04',
|
||||
customer_id: 1,
|
||||
total: '34.20',
|
||||
quotation: 0,
|
||||
line_items: [hiboutikLine()],
|
||||
...overrides,
|
||||
}) as SaleHiboutikDto;
|
||||
|
||||
describe('OrderConverter', () => {
|
||||
const converter = new OrderConverter();
|
||||
|
||||
it('maps a completed Hiboutik sale with its lines', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
|
||||
hiboutikSaleId: 5,
|
||||
orderedAt: new Date('2026-10-18T11:01:04'),
|
||||
source: 'STORE',
|
||||
status: 'COMPLETED',
|
||||
total: '34.20',
|
||||
customer: { connect: { hiboutikId: 1 } },
|
||||
lines: [
|
||||
{
|
||||
barcode: '2430010007467',
|
||||
hiboutikProductId: 746,
|
||||
hiboutikSizeId: 1,
|
||||
productName: 'KATSEYE - WILD',
|
||||
quantity: 2,
|
||||
unitPrice: '17.10',
|
||||
versionName: 'Daniela',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a sale not completed yet as PENDING, ordered at its creation date', () => {
|
||||
const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' }));
|
||||
expect(order.status).toBe('PENDING');
|
||||
expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21'));
|
||||
});
|
||||
|
||||
it('throws when the sale has no valid date', () => {
|
||||
expect(() => converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00', created_at: '' }))).toThrow();
|
||||
});
|
||||
|
||||
it('leaves an anonymous sale without customer', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale({ customer_id: 0 })).customer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses the product name as version name and null barcode for a product without sizes', () => {
|
||||
const [line] = converter.fromHiboutik(
|
||||
hiboutikSale({ line_items: [hiboutikLine({ product_size: 0, size_name: '', product_barcode: '' })] }),
|
||||
).lines;
|
||||
expect(line.versionName).toBe('KATSEYE - WILD');
|
||||
expect(line.barcode).toBeNull();
|
||||
expect(line.hiboutikSizeId).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts a sale without lines', () => {
|
||||
expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
49
src/models/converter/order.converter.ts
Normal file
49
src/models/converter/order.converter.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { OrderSource } from '../../../prisma/generated/client';
|
||||
import { OrderWithLinesInput } from '../../../prisma/customObjects/prisma.custom.object';
|
||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
import { OrderStatus } from '../orderStatus';
|
||||
|
||||
@Injectable()
|
||||
export class OrderConverter extends Converter<OrderWithLinesInput, SaleHiboutikDto> {
|
||||
/**
|
||||
* Maps a Hiboutik sale to an order (source STORE) with a snapshot of its lines.
|
||||
* @throws if the sale has neither a valid completion date nor a valid creation date.
|
||||
*/
|
||||
fromHiboutik(dto: SaleHiboutikDto): OrderWithLinesInput {
|
||||
const completedAt = parseHiboutikDate(dto.completed_at);
|
||||
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
|
||||
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
|
||||
|
||||
const customerId = Number(dto.customer_id);
|
||||
return {
|
||||
hiboutikSaleId: Number(dto.sale_id),
|
||||
orderedAt,
|
||||
source: OrderSource.STORE,
|
||||
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
|
||||
total: dto.total,
|
||||
// 0 for an anonymous sale
|
||||
customer: customerId > 0 ? { connect: { hiboutikId: customerId } } : undefined,
|
||||
lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)),
|
||||
};
|
||||
}
|
||||
|
||||
private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] {
|
||||
return {
|
||||
barcode: line.product_barcode?.trim() || null,
|
||||
hiboutikProductId: Number(line.product_id),
|
||||
hiboutikSizeId: Number(line.product_size),
|
||||
productName: line.product_model,
|
||||
quantity: Number(line.quantity),
|
||||
unitPrice: line.item_unit_gross,
|
||||
// same rule as ProductVersion: the product name for a product without sizes
|
||||
versionName: line.size_name?.trim() || line.product_model,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): SaleHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
59
src/models/converter/product.converter.spec.ts
Normal file
59
src/models/converter/product.converter.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ProductConverter } from './product.converter';
|
||||
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
|
||||
|
||||
const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHiboutikDto => ({
|
||||
product_id: 1,
|
||||
product_model: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||
product_barcode: '8435497280048',
|
||||
product_brand: 1,
|
||||
product_supplier: 2,
|
||||
product_price: '13.99',
|
||||
product_discount_price: '0.00',
|
||||
product_supply_price: '7.49',
|
||||
product_category: 19,
|
||||
product_display_www: 0,
|
||||
product_arch: 0,
|
||||
products_desc: '',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
product_specific_rules: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('ProductConverter', () => {
|
||||
const converter = new ProductConverter();
|
||||
|
||||
it('maps a Hiboutik product', () => {
|
||||
expect(converter.fromHiboutik(hiboutikProduct())).toEqual({
|
||||
archived: false,
|
||||
barcode: '8435497280048',
|
||||
brand: { connect: { hiboutikId: 1 } },
|
||||
category: { connect: { hiboutikId: 19 } },
|
||||
description: '',
|
||||
discountPrice: null,
|
||||
hiboutikId: 1,
|
||||
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||
price: '13.99',
|
||||
enabled: false,
|
||||
supplier: { connect: { hiboutikId: 2 } },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps archived, web display and discount price', () => {
|
||||
const product = converter.fromHiboutik(hiboutikProduct({ product_arch: 1, product_display_www: 1, product_discount_price: '9.99' }));
|
||||
expect(product.archived).toBe(true);
|
||||
expect(product.enabled).toBe(true);
|
||||
expect(product.discountPrice).toBe('9.99');
|
||||
});
|
||||
|
||||
it('stores an empty barcode as null', () => {
|
||||
expect(converter.fromHiboutik(hiboutikProduct({ product_barcode: '' })).barcode).toBeNull();
|
||||
});
|
||||
|
||||
it('does not link missing relations', () => {
|
||||
const product = converter.fromHiboutik(hiboutikProduct({ product_brand: 0, product_category: 0, product_supplier: 0 }));
|
||||
expect(product.brand).toBeUndefined();
|
||||
expect(product.category).toBeUndefined();
|
||||
expect(product.supplier).toBeUndefined();
|
||||
});
|
||||
});
|
||||
31
src/models/converter/product.converter.ts
Normal file
31
src/models/converter/product.converter.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Converter } from './converter';
|
||||
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
|
||||
import { ProductCreateInput } from '../../../prisma/generated/models/Product';
|
||||
import { parseHiboutikDate } from '../../utils/date';
|
||||
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
||||
|
||||
@Injectable()
|
||||
export class ProductConverter extends Converter<ProductCreateInput, ProductHiboutikDto> {
|
||||
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
||||
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
||||
return {
|
||||
archived: dto.product_arch == 1,
|
||||
barcode: dto.product_barcode || null,
|
||||
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
||||
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
||||
description: dto.products_desc,
|
||||
discountPrice: parseOptionalHiboutikPrice(dto.product_discount_price),
|
||||
hiboutikId: dto.product_id,
|
||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||
name: dto.product_model,
|
||||
price: dto.product_price,
|
||||
enabled: dto.product_display_www == 1,
|
||||
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): ProductHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
19
src/models/converter/supplier.converter.ts
Normal file
19
src/models/converter/supplier.converter.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SupplierHiboutikDto } from '../hiboutikDTO/supplier.hiboutik.dto';
|
||||
import { Converter } from './converter';
|
||||
import { SupplierCreateInput } from '../../../prisma/generated/models/Supplier';
|
||||
|
||||
@Injectable()
|
||||
export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHiboutikDto> {
|
||||
fromHiboutik(dto: SupplierHiboutikDto): SupplierCreateInput {
|
||||
return {
|
||||
hiboutikId: dto.supplier_id,
|
||||
name: dto.supplier_name,
|
||||
enabled: dto.supplier_enabled == 1,
|
||||
};
|
||||
}
|
||||
|
||||
toHiboutik(): SupplierHiboutikDto {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
7
src/models/hiboutikDTO/brand.hiboutik.dto.ts
Normal file
7
src/models/hiboutikDTO/brand.hiboutik.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
18
src/models/hiboutikDTO/category.hiboutik.dto.ts
Normal file
18
src/models/hiboutikDTO/category.hiboutik.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
91
src/models/hiboutikDTO/customer.hiboutik.dto.ts
Normal file
91
src/models/hiboutikDTO/customer.hiboutik.dto.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
46
src/models/hiboutikDTO/product.hiboutik.dto.ts
Normal file
46
src/models/hiboutikDTO/product.hiboutik.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
10
src/models/hiboutikDTO/stock.hiboutik.dto.ts
Normal file
10
src/models/hiboutikDTO/stock.hiboutik.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
7
src/models/hiboutikDTO/supplier.hiboutik.dto.ts
Normal file
7
src/models/hiboutikDTO/supplier.hiboutik.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
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];
|
||||
38
src/services/hiboutik/brand.hiboutik.service.ts
Normal file
38
src/services/hiboutik/brand.hiboutik.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BrandHiboutikRepository } from '../../hiboutik/brand.hiboutik.repository';
|
||||
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
|
||||
import { BrandConverter } from '../../models/converter/brand.converter';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
|
||||
@Injectable()
|
||||
export class BrandHiboutikService {
|
||||
private readonly logger = new Logger('BrandHiboutikService');
|
||||
|
||||
constructor(
|
||||
private readonly brandHiboutikRepository: BrandHiboutikRepository,
|
||||
private readonly brandDatabaseRepository: BrandDatabaseRepository,
|
||||
private readonly brandConverter: BrandConverter,
|
||||
) {}
|
||||
|
||||
/** Mirrors Hiboutik brands: upserts each of them, then deletes the brands missing from Hiboutik. */
|
||||
async syncBrands() {
|
||||
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
|
||||
// One upsert per brand: an invalid brand must not prevent the others from being saved
|
||||
const nbFailed = await forEachSafely(
|
||||
this.brandConverter.fromHiboutikList(hiboutikBrands),
|
||||
(brand) => this.brandDatabaseRepository.upsert(brand),
|
||||
this.logger,
|
||||
(brand) => `Hiboutik brand ${brand.hiboutikId}`,
|
||||
);
|
||||
|
||||
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||
if (hiboutikBrands.length === 0) {
|
||||
this.logger.warn('Hiboutik returned no brands: deletion skipped.');
|
||||
return;
|
||||
}
|
||||
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id)));
|
||||
this.logger.log(
|
||||
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/services/hiboutik/category.hiboutik.service.spec.ts
Normal file
89
src/services/hiboutik/category.hiboutik.service.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CategoryHiboutikService } from './category.hiboutik.service';
|
||||
import { CategoryConverter } from '../../models/converter/category.converter';
|
||||
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
|
||||
|
||||
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto =>
|
||||
({
|
||||
category_id: id,
|
||||
category_name: `Category ${id}`,
|
||||
category_id_parent: parentId,
|
||||
category_enabled: 1,
|
||||
category_enabled_www: 0,
|
||||
category_position: 3,
|
||||
...overrides,
|
||||
}) as CategoryHiboutikDto;
|
||||
|
||||
describe('CategoryHiboutikService', () => {
|
||||
const categoryHiboutikRepository = { getCategories: jest.fn() };
|
||||
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
|
||||
let service: CategoryHiboutikService;
|
||||
let calls: string[];
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
calls = [];
|
||||
categoryDatabaseRepository.upsert.mockImplementation((category: { hiboutikId: number }) => {
|
||||
calls.push(`upsert ${category.hiboutikId}`);
|
||||
return Promise.resolve({});
|
||||
});
|
||||
categoryDatabaseRepository.setParent.mockImplementation((id: number) => {
|
||||
calls.push(`parent ${id}`);
|
||||
return Promise.resolve({});
|
||||
});
|
||||
categoryDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
service = new CategoryHiboutikService(
|
||||
categoryHiboutikRepository as never,
|
||||
categoryDatabaseRepository as never,
|
||||
new CategoryConverter(),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores position and web visibility', async () => {
|
||||
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0)]);
|
||||
|
||||
await service.syncCategories();
|
||||
|
||||
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledWith({
|
||||
hiboutikId: 1,
|
||||
name: 'Category 1',
|
||||
enabled: true,
|
||||
position: 3,
|
||||
webEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('links parents once every category is saved, a child being listed before its parent', async () => {
|
||||
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(2, 3), hiboutikCategory(3, 0)]);
|
||||
|
||||
await service.syncCategories();
|
||||
|
||||
expect(calls).toEqual(['upsert 2', 'upsert 3', 'parent 2', 'parent 3']);
|
||||
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(2, 3);
|
||||
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(3, null);
|
||||
});
|
||||
|
||||
it('keeps syncing the other categories when one fails, then deletes the missing ones', async () => {
|
||||
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0), hiboutikCategory(2, 0)]);
|
||||
categoryDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await expect(service.syncCategories()).resolves.toBeUndefined();
|
||||
|
||||
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it('does not delete anything when Hiboutik returns no categories', async () => {
|
||||
categoryHiboutikRepository.getCategories.mockResolvedValue([]);
|
||||
|
||||
await service.syncCategories();
|
||||
|
||||
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
51
src/services/hiboutik/category.hiboutik.service.ts
Normal file
51
src/services/hiboutik/category.hiboutik.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.repository';
|
||||
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
|
||||
import { CategoryConverter } from '../../models/converter/category.converter';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
|
||||
@Injectable()
|
||||
export class CategoryHiboutikService {
|
||||
private readonly logger = new Logger('CategoryHiboutikService');
|
||||
|
||||
constructor(
|
||||
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
|
||||
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
|
||||
private readonly categoryConverter: CategoryConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
|
||||
* then deletes the categories missing from Hiboutik.
|
||||
*/
|
||||
async syncCategories() {
|
||||
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
|
||||
|
||||
// Pass 1: categories themselves. One upsert per category: an invalid category must not prevent the others from being saved
|
||||
const nbFailed = await forEachSafely(
|
||||
this.categoryConverter.fromHiboutikList(hiboutikCategories),
|
||||
(category) => this.categoryDatabaseRepository.upsert(category),
|
||||
this.logger,
|
||||
(category) => `Hiboutik category ${category.hiboutikId}`,
|
||||
);
|
||||
|
||||
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
|
||||
const nbParentsFailed = await forEachSafely(
|
||||
hiboutikCategories,
|
||||
(dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null),
|
||||
this.logger,
|
||||
(dto) => `parent of Hiboutik category ${dto.category_id}`,
|
||||
);
|
||||
|
||||
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||
if (hiboutikCategories.length === 0) {
|
||||
this.logger.warn('Hiboutik returned no categories: deletion skipped.');
|
||||
return;
|
||||
}
|
||||
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id)));
|
||||
this.logger.log(
|
||||
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
|
||||
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
206
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal file
206
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||
|
||||
describe('CustomerHiboutikService', () => {
|
||||
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(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
customerHiboutikRepository.getCustomers.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,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes customers missing from Hiboutik', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]);
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not delete customers when Hiboutik returns none', async () => {
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps syncing the other customers when one fails', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]);
|
||||
customerHiboutikRepository.getCustomerById.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await expect(service.syncCustomers()).resolves.toBeUndefined();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/services/hiboutik/customer.hiboutik.service.ts
Normal file
150
src/services/hiboutik/customer.hiboutik.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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 {
|
||||
private readonly logger = new Logger('CustomerHiboutikService');
|
||||
|
||||
constructor(
|
||||
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
|
||||
* 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();
|
||||
|
||||
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||
let nbDeleted = 0;
|
||||
if (hiboutikCustomers.length === 0) {
|
||||
this.logger.warn('Hiboutik returned no customers: deletion skipped.');
|
||||
} else {
|
||||
nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id)));
|
||||
}
|
||||
|
||||
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);
|
||||
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, ${nbFailedOrders} orders failed.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts
|
||||
* each of them (a failing address is logged and skipped).
|
||||
* @returns the number of failed addresses.
|
||||
*/
|
||||
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
|
||||
if (!Array.isArray(hiboutikCustomer.addresses)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await this.customerAddressDatabaseRepository.deleteMissing(
|
||||
customerId,
|
||||
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
|
||||
);
|
||||
return forEachSafely(
|
||||
hiboutikCustomer.addresses,
|
||||
// Linked to the parent customer, whatever the embedded customers_id says
|
||||
async (dto) => {
|
||||
const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto;
|
||||
await this.customerAddressDatabaseRepository.upsert(
|
||||
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes an embedded address with its company details (one API call). On failure, the embedded address is returned
|
||||
* as is, so the address is still synced and its stored company details are kept.
|
||||
*/
|
||||
private async withCompanyDetails(
|
||||
dto: CustomerAddressHiboutikDto,
|
||||
): Promise<CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto> {
|
||||
try {
|
||||
const details = await this.customerHiboutikRepository.getAddressById(dto.address_id);
|
||||
return {
|
||||
...dto,
|
||||
tax_number: details.tax_number,
|
||||
company_number: details.company_number,
|
||||
legal_status: details.legal_status,
|
||||
updated_at: details.updated_at,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the sales of a full Hiboutik customer as orders: each sale not yet stored as completed is fetched (for its
|
||||
* lines) and upserted (a failing sale is logged and skipped). Quotations are ignored. Orders are never deleted.
|
||||
* @returns the number of failed sales.
|
||||
*/
|
||||
private async syncOrders(hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
||||
if (!Array.isArray(hiboutikCustomer.sales)) {
|
||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no sales array: orders sync skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
||||
hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)),
|
||||
);
|
||||
const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id)));
|
||||
|
||||
return forEachSafely(
|
||||
salesToSync,
|
||||
async (dto) => {
|
||||
const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id);
|
||||
if (Number(sale.quotation) === 1) return;
|
||||
// Linked to the parent customer, whatever the sale says
|
||||
await this.orderDatabaseRepository.upsert(
|
||||
this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }),
|
||||
);
|
||||
},
|
||||
this.logger,
|
||||
(dto) => `Hiboutik sale ${dto.sale_id} of customer ${hiboutikCustomer.customers_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/services/hiboutik/hiboutik.service.spec.ts
Normal file
68
src/services/hiboutik/hiboutik.service.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { HiboutikService } from './hiboutik.service';
|
||||
|
||||
describe('HiboutikService', () => {
|
||||
const brandService = { syncBrands: jest.fn() };
|
||||
const categoryService = { syncCategories: jest.fn() };
|
||||
const supplierService = { syncSuppliers: jest.fn() };
|
||||
const productService = { syncProducts: jest.fn() };
|
||||
const customerService = { syncCustomers: jest.fn() };
|
||||
let service: HiboutikService;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
brandService.syncBrands.mockResolvedValue(undefined);
|
||||
categoryService.syncCategories.mockResolvedValue(undefined);
|
||||
supplierService.syncSuppliers.mockResolvedValue(undefined);
|
||||
productService.syncProducts.mockResolvedValue(undefined);
|
||||
customerService.syncCustomers.mockResolvedValue(undefined);
|
||||
service = new HiboutikService(
|
||||
brandService as never,
|
||||
categoryService as never,
|
||||
customerService as never,
|
||||
productService as never,
|
||||
supplierService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the steps in dependency order', async () => {
|
||||
const order: string[] = [];
|
||||
brandService.syncBrands.mockImplementation(() => Promise.resolve(order.push('brands')));
|
||||
categoryService.syncCategories.mockImplementation(() => Promise.resolve(order.push('categories')));
|
||||
supplierService.syncSuppliers.mockImplementation(() => Promise.resolve(order.push('suppliers')));
|
||||
productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products')));
|
||||
customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers')));
|
||||
|
||||
await service.syncAll();
|
||||
|
||||
expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']);
|
||||
});
|
||||
|
||||
it('continues with the next steps when one fails', async () => {
|
||||
brandService.syncBrands.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await service.syncAll();
|
||||
|
||||
expect(productService.syncProducts).toHaveBeenCalled();
|
||||
expect(customerService.syncCustomers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a second synchronization while one is running, and unlocks at the end', async () => {
|
||||
let finishBrands: () => void = () => undefined;
|
||||
brandService.syncBrands.mockReturnValue(new Promise<void>((resolve) => (finishBrands = resolve)));
|
||||
|
||||
expect(service.startSync()).toBe(true);
|
||||
expect(service.isSyncInProgress).toBe(true);
|
||||
expect(service.startSync()).toBe(false);
|
||||
|
||||
finishBrands();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(service.isSyncInProgress).toBe(false);
|
||||
});
|
||||
});
|
||||
69
src/services/hiboutik/hiboutik.service.ts
Normal file
69
src/services/hiboutik/hiboutik.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BrandHiboutikService } from './brand.hiboutik.service';
|
||||
import { CategoryHiboutikService } from './category.hiboutik.service';
|
||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||
import { SupplierHiboutikService } from './supplier.hiboutik.service';
|
||||
import { ProductHiboutikService } from './product.hiboutik.service';
|
||||
|
||||
/** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */
|
||||
@Injectable()
|
||||
export class HiboutikService {
|
||||
private readonly logger = new Logger('HiboutikService');
|
||||
/** In-memory lock: only valid while the app runs as a single instance. */
|
||||
private syncInProgress = false;
|
||||
|
||||
constructor(
|
||||
private readonly brandHiboutikService: BrandHiboutikService,
|
||||
private readonly categoryHiboutikService: CategoryHiboutikService,
|
||||
private readonly customerHiboutikService: CustomerHiboutikService,
|
||||
private readonly productHiboutikService: ProductHiboutikService,
|
||||
private readonly supplierHiboutikService: SupplierHiboutikService,
|
||||
) {}
|
||||
|
||||
get isSyncInProgress(): boolean {
|
||||
return this.syncInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a full synchronization in the background.
|
||||
* @returns false if a synchronization is already running (nothing is started).
|
||||
*/
|
||||
startSync(): boolean {
|
||||
if (this.syncInProgress) return false;
|
||||
|
||||
this.syncInProgress = true;
|
||||
void this.syncAll()
|
||||
.catch((error) => this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack))
|
||||
.finally(() => (this.syncInProgress = false));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Runs every sync step in order. A failing step is logged and does not stop the next ones. */
|
||||
async syncAll() {
|
||||
this.logger.log('Starting global Hiboutik synchronization');
|
||||
// Order matters: products connect to brands, categories and suppliers by hiboutikId.
|
||||
const steps: [string, () => Promise<void>][] = [
|
||||
['brands', () => this.brandHiboutikService.syncBrands()],
|
||||
['categories', () => this.categoryHiboutikService.syncCategories()],
|
||||
['suppliers', () => this.supplierHiboutikService.syncSuppliers()],
|
||||
['products', () => this.productHiboutikService.syncProducts()],
|
||||
['customers', () => this.customerHiboutikService.syncCustomers()],
|
||||
];
|
||||
|
||||
const failedSteps: string[] = [];
|
||||
for (const [name, step] of steps) {
|
||||
try {
|
||||
await step();
|
||||
} catch (error) {
|
||||
failedSteps.push(name);
|
||||
this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedSteps.length > 0) {
|
||||
this.logger.warn(`Global Hiboutik synchronization ended with errors (failed steps: ${failedSteps.join(', ')})`);
|
||||
} else {
|
||||
this.logger.log('Global Hiboutik synchronization ended successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/services/hiboutik/product.hiboutik.service.spec.ts
Normal file
162
src/services/hiboutik/product.hiboutik.service.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ProductHiboutikService } from './product.hiboutik.service';
|
||||
import { ProductConverter } from '../../models/converter/product.converter';
|
||||
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
|
||||
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
|
||||
|
||||
const hiboutikProduct = (id: number, overrides: Partial<ProductHiboutikDto> = {}): ProductHiboutikDto => ({
|
||||
product_id: id,
|
||||
product_model: `Product ${id}`,
|
||||
product_barcode: `barcode-${id}`,
|
||||
product_brand: 0,
|
||||
product_supplier: 0,
|
||||
product_price: '10.00',
|
||||
product_discount_price: '0.00',
|
||||
product_supply_price: '5.00',
|
||||
product_category: 0,
|
||||
product_display_www: 1,
|
||||
product_arch: 0,
|
||||
products_desc: '',
|
||||
updated_at: '2026-06-28 17:02:55',
|
||||
product_specific_rules: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({
|
||||
product_id: productId,
|
||||
product_size: size,
|
||||
stock_available: available,
|
||||
product_barcode: '',
|
||||
product_supplier_reference: '',
|
||||
});
|
||||
|
||||
describe('ProductHiboutikService', () => {
|
||||
let service: ProductHiboutikService;
|
||||
let calls: string[];
|
||||
const productHiboutikRepository = { getProducts: jest.fn() };
|
||||
const stockHiboutikRepository = { getStocks: jest.fn() };
|
||||
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
|
||||
const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
calls = [];
|
||||
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
||||
productDatabaseRepository.getAll.mockResolvedValue([]);
|
||||
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
||||
calls.push('deleteProducts');
|
||||
return Promise.resolve(ids.length);
|
||||
});
|
||||
// the saved product mirrors what was upserted, like the database would
|
||||
productDatabaseRepository.upsert.mockImplementation((product: { hiboutikId: number }) => {
|
||||
calls.push('upsertProduct');
|
||||
return Promise.resolve({ ...product, id: `uuid-${product.hiboutikId}` });
|
||||
});
|
||||
productVersionDatabaseRepository.deleteMissing.mockImplementation(() => {
|
||||
calls.push('deleteVersions');
|
||||
return Promise.resolve(0);
|
||||
});
|
||||
productVersionDatabaseRepository.upsert.mockImplementation(() => {
|
||||
calls.push('upsertVersion');
|
||||
return Promise.resolve({});
|
||||
});
|
||||
|
||||
service = new ProductHiboutikService(
|
||||
productHiboutikRepository as never,
|
||||
productDatabaseRepository as never,
|
||||
productVersionDatabaseRepository as never,
|
||||
new ProductConverter(),
|
||||
stockHiboutikRepository as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes products missing from Hiboutik, before upserting', async () => {
|
||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
||||
productDatabaseRepository.getAll.mockResolvedValue([
|
||||
{ id: 'uuid-1', hiboutikId: 1 },
|
||||
{ id: 'uuid-2', hiboutikId: 2 },
|
||||
]);
|
||||
|
||||
await service.syncProducts();
|
||||
|
||||
expect(productDatabaseRepository.deleteAll).toHaveBeenCalledWith(['uuid-2']);
|
||||
expect(calls[0]).toBe('deleteProducts');
|
||||
});
|
||||
|
||||
it('does not delete anything when Hiboutik returns no products', async () => {
|
||||
productHiboutikRepository.getProducts.mockResolvedValue([]);
|
||||
productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]);
|
||||
|
||||
await service.syncProducts();
|
||||
|
||||
expect(productDatabaseRepository.deleteAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a single version for a product without sizes', async () => {
|
||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
||||
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, '4'), stock(2, 0, '99')]);
|
||||
|
||||
await service.syncProducts();
|
||||
|
||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }));
|
||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]);
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith(
|
||||
'uuid-1',
|
||||
expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates one version per size, with specific rules and stocks, after deleting removed sizes', async () => {
|
||||
productHiboutikRepository.getProducts.mockResolvedValue([
|
||||
hiboutikProduct(1, {
|
||||
product_size_details: [
|
||||
{ size_id: 10, size_name: 'S', barcode: 'b-s' },
|
||||
{ size_id: 11, size_name: 'M', barcode: '' },
|
||||
],
|
||||
product_specific_rules: [{ size_id: 10, barcode: 'b-s', product_price: '12.00', product_discount_price: '0.00' }],
|
||||
}),
|
||||
]);
|
||||
stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, '3'), stock(1, 11, '2')]);
|
||||
|
||||
await service.syncProducts();
|
||||
|
||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }));
|
||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11]);
|
||||
expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']);
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(1, 'uuid-1', {
|
||||
barcode: 'b-s',
|
||||
discountPrice: null,
|
||||
hiboutikSizeId: 10,
|
||||
price: '12.00',
|
||||
product: { connect: { id: 'uuid-1' } },
|
||||
stock: 3,
|
||||
versionName: 'S',
|
||||
});
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', {
|
||||
barcode: null,
|
||||
discountPrice: null,
|
||||
hiboutikSizeId: 11,
|
||||
price: null,
|
||||
product: { connect: { id: 'uuid-1' } },
|
||||
stock: 2,
|
||||
versionName: 'M',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps syncing the other products when one fails', async () => {
|
||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]);
|
||||
productDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await expect(service.syncProducts()).resolves.toBeUndefined();
|
||||
|
||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
131
src/services/hiboutik/product.hiboutik.service.ts
Normal file
131
src/services/hiboutik/product.hiboutik.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ProductHiboutikRepository } from '../../hiboutik/product.hiboutik.repository';
|
||||
import { ProductDatabaseRepository } from '../../database/product.database.repository';
|
||||
import { ProductVersionDatabaseRepository } from '../../database/productVersion.database.repository';
|
||||
import { ProductConverter } from '../../models/converter/product.converter';
|
||||
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
|
||||
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
|
||||
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
|
||||
import { Product } from '../../../prisma/generated/client';
|
||||
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
|
||||
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
||||
|
||||
/** Version id used for products that have no sizes in Hiboutik. */
|
||||
const NO_SIZE_ID = 0;
|
||||
|
||||
@Injectable()
|
||||
export class ProductHiboutikService {
|
||||
private readonly logger = new Logger('ProductHiboutikService');
|
||||
|
||||
constructor(
|
||||
private readonly productHiboutikRepository: ProductHiboutikRepository,
|
||||
private readonly productDatabaseRepository: ProductDatabaseRepository,
|
||||
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
|
||||
private readonly productConverter: ProductConverter,
|
||||
private readonly stockHiboutikRepository: StockHiboutikRepository,
|
||||
) {}
|
||||
|
||||
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
|
||||
async syncProducts() {
|
||||
this.logger.log('Starting products sync');
|
||||
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
|
||||
this.productHiboutikRepository.getProducts(),
|
||||
this.stockHiboutikRepository.getStocks(),
|
||||
this.productDatabaseRepository.getAll(),
|
||||
]);
|
||||
|
||||
// Deletion happens before upserts so that a product recreated in Hiboutik (new id, same barcode)
|
||||
// does not collide with the old one on the unique barcode.
|
||||
const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts);
|
||||
|
||||
let nbFailed = 0;
|
||||
for (const productHiboutik of productsHiboutik) {
|
||||
const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id));
|
||||
try {
|
||||
await this.updateProduct(productHiboutik, hiboutikProductStocks);
|
||||
} catch (error) {
|
||||
nbFailed++;
|
||||
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns the number of deleted products. */
|
||||
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
|
||||
// Safety net: an empty answer from Hiboutik most likely means an API issue, not an empty catalog
|
||||
if (productsHiboutik.length === 0 && existingProducts.length > 0) {
|
||||
this.logger.warn(`Hiboutik returned no products while ${existingProducts.length} exist in database: deletion skipped.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id)));
|
||||
const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id);
|
||||
return this.productDatabaseRepository.deleteAll(missingProductIds);
|
||||
}
|
||||
|
||||
/** Upserts a product with its global stock, then replaces its versions. */
|
||||
private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise<void> {
|
||||
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
|
||||
newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0);
|
||||
|
||||
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct);
|
||||
const versions = this.buildVersions(productHiboutik, productStocks, savedProduct);
|
||||
|
||||
// Same reason as for products: delete removed versions first to free their barcodes
|
||||
await this.productVersionDatabaseRepository.deleteMissing(
|
||||
savedProduct.id,
|
||||
versions.map((version) => version.hiboutikSizeId),
|
||||
);
|
||||
for (const version of versions) {
|
||||
await this.productVersionDatabaseRepository.upsert(savedProduct.id, version);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the versions of a product: one per Hiboutik size, or a single {@link NO_SIZE_ID} version
|
||||
* copying the product's barcode, prices and stock when it has no sizes.
|
||||
*/
|
||||
private buildVersions(
|
||||
productHiboutik: ProductHiboutikDto,
|
||||
productStocks: StockHiboutikDto[],
|
||||
savedProduct: Product,
|
||||
): ProductVersionCreateInput[] {
|
||||
const specificRules = productHiboutik.product_specific_rules ?? [];
|
||||
const sizesDetails = productHiboutik.product_size_details ?? [];
|
||||
|
||||
if (sizesDetails.length === 0) {
|
||||
return [
|
||||
{
|
||||
barcode: savedProduct.barcode,
|
||||
discountPrice: savedProduct.discountPrice,
|
||||
hiboutikSizeId: NO_SIZE_ID,
|
||||
price: savedProduct.price,
|
||||
product: { connect: { id: savedProduct.id } },
|
||||
stock: savedProduct.globalStock,
|
||||
versionName: savedProduct.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return sizesDetails.map((sizeDetails) => {
|
||||
const sizeId = Number(sizeDetails.size_id);
|
||||
const specificRule = specificRules.find((rule) => Number(rule.size_id) === sizeId);
|
||||
const stock = productStocks.find((stock) => stock.product_size === sizeId);
|
||||
// Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep
|
||||
// the previous value on update when a rule or a stock is removed in Hiboutik.
|
||||
return {
|
||||
barcode: sizeDetails.barcode || null,
|
||||
discountPrice: parseOptionalHiboutikPrice(specificRule?.product_discount_price),
|
||||
hiboutikSizeId: sizeId,
|
||||
price: parseOptionalHiboutikPrice(specificRule?.product_price),
|
||||
product: { connect: { id: savedProduct.id } },
|
||||
stock: stock ? Number(stock.stock_available) : 0,
|
||||
versionName: sizeDetails.size_name,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
38
src/services/hiboutik/supplier.hiboutik.service.ts
Normal file
38
src/services/hiboutik/supplier.hiboutik.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SupplierHiboutikRepository } from '../../hiboutik/supplier.hiboutik.repository';
|
||||
import { SupplierDatabaseRepository } from '../../database/supplier.database.repository';
|
||||
import { SupplierConverter } from '../../models/converter/supplier.converter';
|
||||
import { forEachSafely } from '../../utils/sync';
|
||||
|
||||
@Injectable()
|
||||
export class SupplierHiboutikService {
|
||||
private readonly logger = new Logger('SupplierHiboutikService');
|
||||
|
||||
constructor(
|
||||
private readonly supplierHiboutikRepository: SupplierHiboutikRepository,
|
||||
private readonly supplierDatabaseRepository: SupplierDatabaseRepository,
|
||||
private readonly supplierConverter: SupplierConverter,
|
||||
) {}
|
||||
|
||||
/** Mirrors Hiboutik suppliers: upserts each of them, then deletes the suppliers missing from Hiboutik. */
|
||||
async syncSuppliers() {
|
||||
const hiboutikSuppliers = await this.supplierHiboutikRepository.getSuppliers();
|
||||
// One upsert per supplier: an invalid supplier must not prevent the others from being saved
|
||||
const nbFailed = await forEachSafely(
|
||||
this.supplierConverter.fromHiboutikList(hiboutikSuppliers),
|
||||
(supplier) => this.supplierDatabaseRepository.upsert(supplier),
|
||||
this.logger,
|
||||
(supplier) => `Hiboutik supplier ${supplier.hiboutikId}`,
|
||||
);
|
||||
|
||||
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||
if (hiboutikSuppliers.length === 0) {
|
||||
this.logger.warn('Hiboutik returned no suppliers: deletion skipped.');
|
||||
return;
|
||||
}
|
||||
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id)));
|
||||
this.logger.log(
|
||||
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/utils/date.spec.ts
Normal file
15
src/utils/date.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseHiboutikDate } from './date';
|
||||
|
||||
describe('parseHiboutikDate', () => {
|
||||
it('parses a Hiboutik datetime', () => {
|
||||
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55'));
|
||||
});
|
||||
|
||||
it('parses a date without time', () => {
|
||||
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12'));
|
||||
});
|
||||
|
||||
it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => {
|
||||
expect(parseHiboutikDate(value)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
10
src/utils/date.ts
Normal file
10
src/utils/date.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD").
|
||||
* Returns undefined for empty or invalid dates (e.g. "0000-00-00").
|
||||
*/
|
||||
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
const date = new Date(value.trim().replace(' ', 'T'));
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
11
src/utils/price.spec.ts
Normal file
11
src/utils/price.spec.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { parseOptionalHiboutikPrice } from './price';
|
||||
|
||||
describe('parseOptionalHiboutikPrice', () => {
|
||||
it('keeps a positive price', () => {
|
||||
expect(parseOptionalHiboutikPrice('12.50')).toBe('12.50');
|
||||
});
|
||||
|
||||
it.each(['0.00', '0', '', 'abc', '-1.00', null, undefined])('returns null for %p', (value) => {
|
||||
expect(parseOptionalHiboutikPrice(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
10
src/utils/price.ts
Normal file
10
src/utils/price.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parses an optional Hiboutik price (discount price, size price override).
|
||||
* Hiboutik sends "0.00" when the value is not set: returns null for empty, zero or invalid values.
|
||||
*/
|
||||
export function parseOptionalHiboutikPrice(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
const price = Number(value);
|
||||
return Number.isFinite(price) && price > 0 ? value : null;
|
||||
}
|
||||
24
src/utils/sync.ts
Normal file
24
src/utils/sync.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed.
|
||||
* @param describe label of an item in the error log, e.g. "Hiboutik brand 12".
|
||||
* @returns the number of failed items.
|
||||
*/
|
||||
export async function forEachSafely<T>(
|
||||
items: T[],
|
||||
action: (item: T) => Promise<unknown>,
|
||||
logger: Logger,
|
||||
describe: (item: T) => string,
|
||||
): Promise<number> {
|
||||
let nbFailed = 0;
|
||||
for (const item of items) {
|
||||
try {
|
||||
await action(item);
|
||||
} catch (error) {
|
||||
nbFailed++;
|
||||
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
|
||||
}
|
||||
}
|
||||
return nbFailed;
|
||||
}
|
||||
@@ -3,24 +3,37 @@ import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
import { HiboutikService } from './../src/services/hiboutik/hiboutik.service';
|
||||
import { PrismaService } from './../prisma/prisma.service';
|
||||
|
||||
// Boots the real AppModule wiring, without database nor Hiboutik calls
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
const hiboutikService = { startSync: jest.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
hiboutikService.startSync.mockReset();
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
})
|
||||
.overrideProvider(PrismaService)
|
||||
.useValue({})
|
||||
.overrideProvider(HiboutikService)
|
||||
.useValue(hiboutikService)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
it('POST /sync starts a synchronization (202)', () => {
|
||||
hiboutikService.startSync.mockReturnValue(true);
|
||||
return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' });
|
||||
});
|
||||
|
||||
it('POST /sync answers 409 when a synchronization is already running', () => {
|
||||
hiboutikService.startSync.mockReturnValue(false);
|
||||
return request(app.getHttpServer()).post('/sync').expect(409);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+.ts$": "ts-jest",
|
||||
"^.+.js$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": {
|
||||
"allowJs": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"transformIgnorePatterns": ["/node_modules/(?!@nestjs/config/)"],
|
||||
"moduleNameMapper": {
|
||||
"^(.{1,2}/.*).js$": "$1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user