feat(sync): synchronize Hiboutik data through POST /sync
This commit is contained in:
@@ -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,67 @@
|
|||||||
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 { 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,
|
||||||
|
CustomerHiboutikRepository,
|
||||||
|
CustomerDatabaseRepository,
|
||||||
|
CustomerConverter,
|
||||||
|
CustomerHiboutikService,
|
||||||
|
HiboutikClient,
|
||||||
|
HiboutikService,
|
||||||
|
ProductConverter,
|
||||||
|
ProductDatabaseRepository,
|
||||||
|
ProductHiboutikRepository,
|
||||||
|
ProductHiboutikService,
|
||||||
|
ProductVersionDatabaseRepository,
|
||||||
|
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!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal file
51
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||||
|
|
||||||
|
describe('CustomerHiboutikService', () => {
|
||||||
|
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
||||||
|
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||||
|
const customerConverter = { fromHiboutik: jest.fn() };
|
||||||
|
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({});
|
||||||
|
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||||
|
service = new CustomerHiboutikService(
|
||||||
|
customerHiboutikRepository as never,
|
||||||
|
customerDatabaseRepository as never,
|
||||||
|
customerConverter 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/services/hiboutik/customer.hiboutik.service.ts
Normal file
49
src/services/hiboutik/customer.hiboutik.service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerHiboutikService {
|
||||||
|
private readonly logger = new Logger('CustomerHiboutikService');
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||||
|
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||||
|
private readonly customerConverter: CustomerConverter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
||||||
|
* (one API call per customer, as the list endpoint lacks addresses and sales).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||||
|
try {
|
||||||
|
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||||
|
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||||
|
// retreive addresses DTOs
|
||||||
|
// save addresses
|
||||||
|
// retreive orders DTOs
|
||||||
|
// save orders
|
||||||
|
} 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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user