Compare commits
10 Commits
main
...
1a04bafa0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a04bafa0b | ||
|
|
ad8e39f63c | ||
|
|
9e6e5162ad | ||
|
|
c5f65dcb62 | ||
|
|
27d0e0149d | ||
|
|
db699822f9 | ||
|
|
2424a086e0 | ||
|
|
8f377a1fe9 | ||
|
|
a0ddef240c | ||
|
|
24b699cfc4 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -37,10 +37,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# dotenv environment variable files
|
# dotenv environment variable files
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
!.env.example
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# temp directory
|
# temp directory
|
||||||
.temp
|
.temp
|
||||||
@@ -54,3 +52,6 @@ pids
|
|||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Prisma client (regenerated by `prisma generate`, run on npm install)
|
||||||
|
/prisma/generated/
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "all"
|
"trailingComma": "all",
|
||||||
|
"printWidth": 140,
|
||||||
|
"tabWidth": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
sourceType: 'commonjs',
|
sourceType: 'commonjs',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
projectService: true,
|
//projectService: true,
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
project: './tsconfig.json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -29,7 +30,7 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
"prettier/prettier": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
5747
package-lock.json
generated
5747
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,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "prisma generate --config prisma7.config.ts",
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
@@ -22,21 +23,26 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^12.0.0",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^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",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^12.0.11",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.0.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -44,6 +50,7 @@
|
|||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
|
"prisma": "^7.10.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
@@ -62,12 +69,33 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+.ts$": "ts-jest",
|
||||||
|
"^.+.js$": [
|
||||||
|
"ts-jest",
|
||||||
|
{
|
||||||
|
"tsconfig": {
|
||||||
|
"allowJs": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||||
|
|
||||||
describe('AppController', () => {
|
describe('AppController', () => {
|
||||||
let appController: AppController;
|
let controller: AppController;
|
||||||
|
const hiboutikService = { startSync: jest.fn() };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const app: TestingModule = await Test.createTestingModule({
|
hiboutikService.startSync.mockReset();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [{ provide: HiboutikService, useValue: hiboutikService }],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
controller = module.get(AppController);
|
||||||
appController = app.get<AppController>(AppController);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('root', () => {
|
it('starts a synchronization', () => {
|
||||||
it('should return "Hello World!"', () => {
|
hiboutikService.startSync.mockReturnValue(true);
|
||||||
expect(appController.getHello()).toBe('Hello World!');
|
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 { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
import { AppService } from './app.service';
|
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly appService: AppService) {}
|
constructor(private readonly hiboutikService: HiboutikService) {}
|
||||||
|
|
||||||
@Get()
|
/** Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. */
|
||||||
getHello(): string {
|
@Post('sync')
|
||||||
return this.appService.getHello();
|
@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 { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
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({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
HttpModule.register({ timeout: 30_000 }),
|
||||||
|
PrismaModule,
|
||||||
|
],
|
||||||
controllers: [AppController],
|
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 {}
|
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);
|
const app = await NestFactory.create(AppModule);
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
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 request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
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)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
const hiboutikService = { startSync: jest.fn() };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
hiboutikService.startSync.mockReset();
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideProvider(PrismaService)
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(HiboutikService)
|
||||||
|
.useValue(hiboutikService)
|
||||||
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
it('POST /sync starts a synchronization (202)', () => {
|
||||||
return request(app.getHttpServer())
|
hiboutikService.startSync.mockReturnValue(true);
|
||||||
.get('/')
|
return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' });
|
||||||
.expect(200)
|
});
|
||||||
.expect('Hello World!');
|
|
||||||
|
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 () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"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